Compare commits

..

6 Commits

Author SHA1 Message Date
anonymous
5d5cbdd178 fix: Address code review findings and add ViewModel tests
Code review fixes:
- Remove duplicate KeyConnectorUrl branch in InitUserCryptoMethodExtensions
- Fix CollectionManagerTest createCollection calls to include organizationUserId
- Prevent vault sync from overwriting user's in-progress edits in
  CollectionAddEditViewModel (early return if already in Content state)
- Add per-collection canManage permission check before allowing edit
  navigation, based on collection manage flag and org role
- Gitignore .claude/outputs/ to exclude plan documents from commits

New tests:
- CollectionsViewModelTest: 11 tests covering navigation, state updates,
  FAB visibility based on permissions, snackbar relay, and error states
- CollectionAddEditViewModelTest: 20 tests covering create/edit/delete
  flows, name validation, dialog states, snackbar relay, and the sync
  overwrite protection fix

Updated test fixtures:
- SyncResponseProfileUtil: add organizationUserId, limitCollectionCreation,
  limitCollectionDeletion fields
2026-03-25 14:02:03 -04:00
anonymous
5dcaf6e4a8 fix: Grant creating user manage access and fix permission checks
- Add organizationUserId to SyncResponseJson and Organization domain
  model to identify the current user's org membership ID
- Include creating user with manage access in collection create request,
  matching web client behavior
- Add limitCollectionCreation/limitCollectionDeletion to org model
- Fix FAB visibility: use canManageCollections computed property that
  checks role (Owner/Admin) in addition to permissions flags, matching
  web client logic: !limitCollectionCreation || isAdmin || permissions
2026-03-25 13:08:14 -04:00
Patrick Honkonen
d0809a7c07 fix: Include access permissions in collection update request
The PUT endpoint for updating a collection requires groups and users
access permissions in the request body. Previously only the encrypted
name was sent, causing the server to reject the request with "At least
one member or group must have can manage permission."

The update flow now fetches collection details via the new /details
endpoint before sending the PUT request, echoing back existing groups,
users, and externalId. Also fixes collection edit screen passing
organizationName instead of organizationId and resolves compile errors
from new parameters across tests.
2026-03-24 16:44:16 -04:00
anonymous
27eab5570f fix: Adapt to local SDK API changes for local development
Add vaultUrl parameter to SsoCookieVendorConfig and handle new
KeyConnectorUrl variant in InitUserCryptoMethod when expressions.
These changes are required for compatibility with the latest
sdk-internal build used for local collection encryption testing.
2026-03-24 15:09:33 -04:00
anonymous
f6435a0a1e feat: Replace encryptCollection stub with real SDK call
Remove the UnsupportedOperationException stub and delegate to the
actual SDK collections().encrypt() method. Requires SDK version with
collection encryption support (not yet in published 2.0.0-5451).
2026-03-24 15:09:33 -04:00
anonymous
d3e4dc854b feat: Add collection management (create, edit, delete) to Settings > Vault
Add full CRUD support for managing collections on Android, accessible
via Settings > Vault > Collections. Collections are organization-scoped
vault items available on paid plans.

Changes include:
- Network layer: CollectionsApi, CollectionService, request/response models
- Data layer: CollectionManager with encrypt > API > disk > decrypt flow
- Permission model: expanded SyncResponseJson.Permissions and Organization
  with collection-specific permission fields
- UI: CollectionsScreen (list with org subtitles, permission-gated FAB),
  CollectionAddEditScreen (name field, save, delete with confirmation)
- Navigation: type-safe routes wired through VaultSettings entry point
- VaultDiskSource.deleteCollection and VaultSdkSource.encryptCollection stub

Note: encryptCollection is stubbed pending SDK release (SDK changes are
implemented but not yet published). Create/update will fail at runtime
until the SDK is updated.
2026-03-24 15:09:29 -04:00
527 changed files with 9658 additions and 25675 deletions

View File

@@ -58,27 +58,22 @@ User Request (UI Action)
### Workflow Skills
> **Quick start**: Use the `bitwarden-tech-lead:bitwarden-tech-lead` agent (or `/plan-android-work <task>`) to refine
> requirements and plan,
> then the `bitwarden-software-engineer:bitwarden-software-engineer` 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.
## Skills & Commands
Planning: 12 | Implementation: 37 | Review & PR: 810
| Skill | Triggers |
|-------|---------|
| `build-test-verify` | "build", "run tests", "lint", "format", "verify build" |
| `implementing-android-code` | "implement", "write code", "add screen", "create feature" |
| `planning-android-implementation` | "plan implementation", "architecture design", "phased task breakdown" |
| `refining-android-requirements` | "refine requirements", "analyze ticket", "gap analysis" |
| `reviewing-changes` | "review", "code review", "check PR" |
| `testing-android-code` | "write tests", "add test coverage", "unit test" |
| Command | Usage |
|---------|-------|
| `/plan-android-work <task>` | Fetch ticket → refine requirements → design implementation approach |
| `/work-on-android <task>` | Full workflow: implement → test → verify → preflight → commit → review → PR |
| `/review-android <PR#>` | Full review workflow: PR context gathering → Android checklist → output |
1. `refining-android-requirements` - Gap analysis and structured spec from any input source
2. `planning-android-implementation` - Architecture design and phased task breakdown
3. `implementing-android-code` - Patterns, gotchas, and templates for writing code
4. `testing-android-code` - Test patterns and templates for verifying code
5. `build-test-verify` - Build, test, lint, and deploy commands
6. `perform-android-preflight-checklist` - Quality gate before committing
7. `committing-android-changes` - Commit message format and pre-commit workflow
8. `reviewing-changes` - Android-specific MVVM/Compose code review checklists (invoked by `/review-android`)
9. `/review-android` - Full review workflow: PR context gathering → Android checklist → output
10. `creating-android-pull-request` - PR creation workflow and templates
---
@@ -99,7 +94,7 @@ User Request (UI Action)
- **Formatter**: Android Studio with `bitwarden-style.xml` | **Line Limit**: 100 chars | **Detekt**: Enabled
- **Naming**: `camelCase` (vars/fns), `PascalCase` (classes), `SCREAMING_SNAKE_CASE` (constants), `...Impl` (implementations)
- **KDoc**: Required for all public APIs
- **String Resources**: Add new strings to `:ui` module (`ui/src/main/res/values/strings.xml`). Use typographic quotes/apostrophes (`"` `"` `'`) not escaped ASCII (`\"` `\'`). Name each resource from its own text content in `snake_case` — not with generic suffixes (`_message`, `_title`). E.g., `one_or_more_email_addresses_are_incorrect`, not `invalid_email_addresses_message`.
- **String Resources**: Add new strings to `:ui` module (`ui/src/main/res/values/strings.xml`). Use typographic quotes/apostrophes (`"` `"` `'`) not escaped ASCII (`\"` `\'`)
> For complete style rules (imports, formatting, documentation, Compose conventions), see `docs/STYLE_AND_BEST_PRACTICES.md`.
@@ -130,8 +125,8 @@ In addition to the Key Principles above, follow these rules:
- **Before writing code**: Use `implementing-android-code` skill for Bitwarden-specific patterns, gotchas, and templates
- **Before writing tests**: Use `testing-android-code` skill for test patterns and templates
- **Building/testing**: Use `build-test-verify` skill | App tests: `./gradlew app:testStandardDebugUnitTest`
- **Before committing**: Use `bitwarden-delivery-tools:perform-preflight` skill, then `bitwarden-delivery-tools:committing-changes` skill for message format
- **Before committing**: Use `perform-android-preflight-checklist` skill, then `committing-android-changes` skill for message format
- **Code review**: Use `/review-android` for the full review workflow; `reviewing-changes` skill for checklist-only
- **Creating PRs**: Use `bitwarden-delivery-tools:creating-pull-request` skill for PR workflow and templates
- **Creating PRs**: Use `creating-android-pull-request` skill for PR workflow and templates
- **Troubleshooting**: See `docs/TROUBLESHOOTING.md`
- **Architecture**: `docs/ARCHITECTURE.md` | [Bitwarden SDK](https://github.com/bitwarden/sdk) | [Jetpack Compose](https://developer.android.com/jetpack/compose) | [Hilt DI](https://dagger.dev/hilt/)

View File

@@ -1,130 +0,0 @@
# Contributing Claude Context to This Repo
Every time you catch Claude making the same mistake twice, explain the same convention in chat, or
hand a teammate a mental map they didn't have — that's knowledge worth encoding. This guide covers
what belongs in this repo's `.claude/`, where to put it, and how to land it alongside the code it
describes.
## When to contribute here vs. elsewhere
Ask: **is this knowledge specific to this codebase, or generic enough to work across repos?**
- **Specific to this codebase** → contribute here, in `.claude/`. Example: "how we add a new module
in this codebase," "how our feature-flag system works."
- **Generic, reusable across repos** →
[`bitwarden/ai-plugins`](https://github.com/bitwarden/ai-plugins) — persona plugins (e.g., a
code-review agent), tool integrations, or shared utilities.
When unsure, keep it here. Promoting up to `ai-plugins` later is easier than pulling it back — see
its [CONTRIBUTING.md](https://github.com/bitwarden/ai-plugins/blob/main/CONTRIBUTING.md) when you're
ready.
## Choose scope, then shape
### 1. Scope — where does it apply?
Claude loads every `CLAUDE.md` and `CLAUDE.local.md` by
[walking up from the working directory](https://code.claude.com/docs/en/memory#how-claude-md-files-load)
— looking in each ancestor directly, not in a nested `.claude/` subdirectory. Files below the
working directory (including nested `.claude/skills/`) are loaded lazily when Claude reads into that
subtree. Use that hierarchy:
- **Applies everywhere in this repo** → root `CLAUDE.md` or `.claude/skills/`
- **Applies only within one app, library, utility, or subtree** → nested `CLAUDE.md` or
`.claude/skills/` in that directory
Push rules as deep as they'll go — keeping app-specific rules local saves context for everyone
else's sessions, not just yours.
For rules that should apply only to certain file types, use
[`.claude/rules/<name>.md` with a `paths:` frontmatter glob](https://code.claude.com/docs/en/memory#organize-rules-with-claude/rules/)
instead of a nested `CLAUDE.md`.
### 2. Shape — how should Claude use it?
| You want to… | Use |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| State a rule Claude must always follow in its scope | `CLAUDE.md` |
| State a rule that applies only to certain file globs | `.claude/rules/<name>.md` with `paths:` frontmatter |
| Teach a procedure Claude invokes on demand | `.claude/skills/<name>/SKILL.md` |
| Give Claude a specialized subagent with its own context | `.claude/agents/<name>.md` (YAML frontmatter; `name` + `description` required) |
| Add a user-invocable slash command | `.claude/commands/<name>.md` |
| Trigger a shell script on a Claude Code event | _We have them, but no strict project enforcement yet — register yours in `settings.local.json`._ |
Rule of thumb: **if Claude only needs it sometimes, it's a skill.** Once a `CLAUDE.md` loads, it
stays in context for the rest of the session — keep each one lean, especially the root.
## Security conventions
Skills and agents that touch vault data, authentication, or cryptography must use Bitwarden's
[Core Vocabulary](https://contributing.bitwarden.com/architecture/security/definitions) (Vault Data,
Protected Data, Secure Channel, etc.) and re-state the zero-knowledge invariant inline. **Subagents
run in a fresh context** and do not inherit this repo's `CLAUDE.md` — include the relevant
definitions directly in the agent's system prompt.
## What good contributions look like
- **Grounded in the code.** Real files, real patterns, real commands. If it could apply to any repo,
it belongs in `ai-plugins`.
- **Describes the "what" and "why," not the "who."** Avoid team-persona framing. Describe the domain
and its constraints; the team is an implementation detail.
- **Short and specific.** 2,000 words of general advice isn't a skill.
- **Active voice, direct language.** "Invoke this skill when..." — not "This skill may be invoked
when..."
- **Reviewed like code.** Teams of domain experts own `.claude/` in their areas — they're the ones
shaping how Claude behaves for everyone who works there, so treat changes with the same
seriousness as source.
## Anti-patterns
- **Team-persona agents** ("Team ABC engineer"). If a team's process is unique enough to warrant a
persona, that's an SDLC signal to address, not a persona to encode.
- **Root-level rules that only matter in one subtree.** If the rule only ever applies to a single
subtree, then the rule belongs in a nested `CLAUDE.md` next to that subtree.
- **Duplicating `ai-plugins` content.** Check existing plugin skills before writing a new one.
- **Generic advice disguised as repo-local knowledge.** "Write good tests" isn't repo-specific. "Our
integration tests must hit a real database because…" is.
## Building a contribution
The Claude Code ecosystem moves fast — last session's habits may already be out of date. Here's the
workflow we follow.
### 1. Start with the canonical docs
A quick refresh before you begin goes a long way — the rules shift more often than you'd think:
- [How Claude Code Works](https://code.claude.com/docs/en/how-claude-code-works) — the mental model.
- [Best Practices for Claude Code](https://code.claude.com/docs/en/best-practices) — what Anthropic
recommends.
- [Extend Claude Code](https://code.claude.com/docs/en/features-overview) — what you can build
(skills, agents, commands, hooks).
- [The Complete Guide to Building Skills for Claude](https://resources.anthropic.com/hubfs/The-Complete-Guide-to-Building-Skill-for-Claude.pdf) -
a must read for skill building
### 2. Survey the landscape
A quick skim of both goes a long way:
- This repo's [`.claude/`](.) tree.
- [`bitwarden/ai-plugins`](https://github.com/bitwarden/ai-plugins).
Try to match the voice you see. "Invoke when the user asks to X" — not "This skill may be invoked
when X." Direct, active, specific. Your contribution should read like the neighbors.
### 3. Build iteratively
When you're authoring a skill, start with `/skill-creator:skill-creator`. It runs an iterative loop
— draft → test against evaluations → review outputs → refine — with benchmark stats and a
side-by-side reviewer. You end up with a skill that's been exercised against concrete inputs before
you open the PR.
For agents, commands, hooks, and `CLAUDE.md` entries, start from an existing one in the repo and
adapt it. No need to invent a new structure when a neighbor already solves the shape problem.
### 4. Validate before you push
- Run a local Bitwarden Claude Code review with `/bitwarden-code-review:code-review-local` — it
writes findings to files so you can fix them before pushing, without posting anything to GitHub.
- When you raise the PR, apply the `ai-review` label. Our reusable GitHub workflow watches for it
and runs a Claude Code review automatically; without the label, the review doesn't fire.

View File

@@ -0,0 +1,58 @@
---
name: android-implementer
description: "Autonomously implements features, fixes bugs, and completes development tasks on the Bitwarden Android project. Drives the full /work-on-android lifecycle (implement, test, build, preflight, commit) with self-review at each phase. Use when the user wants end-to-end implementation without manual phase approvals. Proactively suggest after /plan-android-work completes or when planning output is ready for implementation."
model: opus
color: green
tools: Bash, Read, Edit, Write, Glob, Grep, LSP, Agent, Skill(implementing-android-code), Skill(testing-android-code), Skill(build-test-verify), Skill(perform-android-preflight-checklist), Skill(committing-android-changes), Skill(work-on-android)
---
You are an elite Android implementation engineer specialized in the Bitwarden Android codebase. Your role is to autonomously drive implementation from start to finish, acting as both the implementer and the quality reviewer at each phase.
## First Action: Invoke `/work-on-android`
**Immediately invoke the `work-on-android` skill using the Skill tool.** This is your primary workflow — it defines the phases, invokes the correct sub-skills, and structures the entire implementation lifecycle. Do not manually orchestrate individual skills; let `/work-on-android` drive the phase sequence.
Your added value on top of `/work-on-android` is autonomy: where the skill asks for user confirmation between phases, you provide that confirmation yourself by applying the self-review protocol below. Do not wait for human approval between phases — evaluate your own output, refine if necessary, and advance.
## Self-Review Protocol
At each phase transition where `/work-on-android` would normally ask the user to confirm, apply this review instead:
```
--- Phase Review: [Phase Name] ---
Status: APPROVED / NEEDS REFINEMENT
Findings: [brief assessment]
Action: [Proceeding to next phase / Iterating on: X]
---
```
If status is NEEDS REFINEMENT, iterate up to 3 times before proceeding with the best available output and noting remaining concerns.
**Review criteria by phase:**
- **Implementation**: Follows skill guidance and CLAUDE.md anti-patterns list?
- **Testing**: Covers happy path, error cases, and edge cases?
- **Build & Verify**: All tests pass? No compilation errors or warnings?
- **Preflight**: Would this pass code review by a senior engineer?
- **Commit**: Message clear, properly formatted, and accurate?
## Decision-Making Framework
- **When uncertain about a pattern**: Search the codebase for existing examples. Follow what exists rather than inventing.
- **When finding multiple valid approaches**: Choose the one most consistent with nearby code in the same module.
- **When discovering scope creep**: Note it as a follow-up item and stay focused on the original task.
- **When tests fail**: Diagnose the root cause, fix it, and re-run. Don't skip failing tests.
- **When a phase produces subpar output**: Iterate. Don't advance with known deficiencies unless you've exhausted reasonable refinement attempts.
## Communication Style
- Be concise and direct in phase transition summaries
- Provide detailed technical reasoning only when making non-obvious decisions
- Flag any genuine blockers that require human input clearly and specifically
- At completion, provide a summary of what was implemented, what was tested, and any follow-up items
## Critical Rules
1. **Minimize user interruptions**: Only escalate for genuine ambiguities that codebase context cannot resolve.
2. **Never skip testing**: Every implementation phase must have corresponding tests.
3. **Never invent new patterns**: Use established codebase patterns. Search for examples first.
4. **Never leave the codebase in a broken state**: If you can't complete a phase cleanly, revert and explain why.

View File

@@ -15,19 +15,19 @@ Work through each phase sequentially. **Confirm with the user before advancing t
### Phase 1: Implement
Invoke `Skill(implementing-android-code)` to guide your implementation of the task. Understand what needs to be done, explore the relevant code, and write the implementation.
Invoke the `implementing-android-code` skill and use it to guide your implementation of the task. Understand what needs to be done, explore the relevant code, and write the implementation.
**Before advancing**: Summarize what was implemented and confirm the user is ready to move to testing.
### Phase 2: Test
Invoke `Skill(testing-android-code)` to write tests for the changes made in Phase 1. Follow the project's test patterns and conventions.
Invoke the `testing-android-code` skill and use it to write tests for the changes made in Phase 1. Follow the project's test patterns and conventions.
**Before advancing**: Summarize what tests were written and confirm readiness for build verification.
### Phase 3: Build & Verify
Invoke `Skill(build-test-verify)` to run tests, lint, and detekt. Ensure everything passes.
Invoke the `build-test-verify` skill to run tests, lint, and detekt. Ensure everything passes.
**If failures occur**: Fix the issues and re-run verification. Do not advance until all checks pass.
@@ -35,13 +35,13 @@ Invoke `Skill(build-test-verify)` to run tests, lint, and detekt. Ensure everyth
### Phase 4: Self-Review
Invoke `Skill(bitwarden-delivery-tools:perform-preflight)` 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(bitwarden-delivery-tools:committing-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(bitwarden-delivery-tools:creating-pull-request)` to create the pull request with proper description and formatting. **Create as a draft PR by default** unless the user has explicitly requested a ready-for-review PR.
Prompt the user to invoke the `creating-android-pull-request` skill to create the pull request with proper description and formatting. **Create as a draft PR by default** unless the user has explicitly requested a ready-for-review PR.
## Guidelines

View File

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

View File

@@ -0,0 +1,81 @@
---
name: committing-android-changes
version: 0.1.0
description: Git commit conventions and workflow for Bitwarden Android. Use when committing code, writing commit messages, or preparing changes for commit. Triggered by "commit", "git commit", "commit message", "prepare commit", "stage changes".
---
# Git Commit Conventions
## Commit Message Format
```
[PM-XXXXX] <type>: <imperative summary>
<optional body explaining why, not what>
```
### Rules
1. **Ticket prefix**: Always include `[PM-XXXXX]` matching the Jira ticket
2. **Type keyword**: Include a conventional commit type after the ticket prefix (see table below)
3. **Imperative mood**: "Add feature" not "Added feature" or "Adds feature"
4. **Short summary**: Under 72 characters for the first line
5. **Body**: Explain the "why" not the "what" — the diff shows the what
### Type Keywords
Invoke the `labeling-android-changes` skill for the full type keyword table and selection guidance.
### Example
```
[PM-12345] feat: Add biometric unlock timeout configuration
Users reported confusion about when biometric prompts appear.
This adds a configurable timeout setting to the security preferences.
```
### Followup Commits
Only the first commit on a branch needs the full format (ticket prefix, type keyword, body). Subsequent commits — whether addressing review feedback, making intermediate changes, or iterating locally — can use a short, descriptive summary with no prefix or body required.
```
Update error handling in login flow
```
---
## Pre-Commit Checklist
Run the `perform-android-preflight-checklist` skill for the full quality gate. At minimum, before staging and committing:
1. **Run affected module tests** (use `build-test-verify` skill for correct commands)
2. **Check lint**: `./gradlew detekt` on changed modules
3. **Review staged changes**: `git diff --staged` — verify no unintended modifications
4. **Verify no secrets**: No API keys, tokens, passwords, or `.env` files staged
5. **Verify no generated files**: No build outputs, `.idea/` changes, or generated code
---
## What NOT to Commit
- `.env` files or `user.properties` with real tokens
- Credential files or signing keystores
- Build outputs (`build/`, `*.apk`, `*.aab`)
- IDE-specific files (`.idea/` changes, `*.iml`)
- Large binary files
---
## Staging Best Practices
- **Stage specific files** by name rather than `git add -A` or `git add .`
- Put each file path on its own line for readability:
```bash
git add \
path/to/first/File.kt \
path/to/second/File.kt \
path/to/third/File.kt
```
- Review each file being staged to avoid accidentally including sensitive data
- Use `git status` (without `-uall` flag) to see the working tree state

View File

@@ -0,0 +1,64 @@
---
name: creating-android-pull-request
version: 0.1.0
description: Pull request creation workflow for Bitwarden Android. Use when creating PRs, writing PR descriptions, or preparing branches for review. Triggered by "create PR", "pull request", "open PR", "gh pr create", "PR description".
---
# Create Pull Request
## PR Title Format
```
[PM-XXXXX] <type>: <short imperative summary>
```
**Examples:**
- `[PM-12345] feat: Add autofill support for passkeys`
- `[PM-12345] fix: Resolve crash during vault sync`
- `[PM-12345] refactor: Simplify authentication flow`
**Rules:**
- Include Jira ticket prefix
- Keep under 70 characters total
- Use imperative mood in the summary
**Type keywords** (triggers automatic `t:` label via CI):
Invoke the `labeling-android-changes` skill for the full type keyword table and selection guidance.
---
## PR Body Template
**IMPORTANT:** Always follow the repo's PR template at `.github/PULL_REQUEST_TEMPLATE.md`. Delete the Screenshots section entirely if there are no UI changes.
---
## Pre-PR Checklist
1. **All tests pass**: Run `./gradlew app:testStandardDebugUnitTest` (and other affected modules)
2. **Lint clean**: Run `./gradlew detekt`
3. **Self-review done**: Use `perform-android-preflight-checklist` skill
4. **No unintended changes**: Check `git diff origin/main...HEAD` for unexpected files
5. **Branch up to date**: Rebase on `main` if needed
---
## Creating the PR
```bash
# Ensure branch is pushed
git push -u origin <branch-name>
# Create PR as draft by default (body follows .github/PULL_REQUEST_TEMPLATE.md)
gh pr create --draft --title "[PM-XXXXX] feat: Short summary" --body "<fill in from PR template>"
```
**Default to draft PRs.** Only create a non-draft (ready for review) PR if the user explicitly requests it.
---
## Base Branch
- Default target: `main`
- Check with team if targeting a feature branch instead

View File

@@ -1,6 +1,6 @@
---
name: implementing-android-code
version: 0.1.3
version: 0.1.2
description: This skill should be used when implementing Android code in Bitwarden. Covers critical patterns, gotchas, and anti-patterns unique to this codebase. Triggered by "How do I implement a ViewModel?", "Create a new screen", "Add navigation", "Write a repository", "BaseViewModel pattern", "State-Action-Event", "type-safe navigation", "@Serializable route", "SavedStateHandle persistence", "process death recovery", "handleAction", "sendAction", "Hilt module", "Repository pattern", "implementing a screen", "adding a data source", "handling navigation", "encrypted storage", "security patterns", "Clock injection", "DataState", or any questions about implementing features, screens, ViewModels, data sources, or navigation in the Bitwarden Android app.
---
@@ -437,42 +437,6 @@ val FIXED_CLOCK = Clock.fixed(
---
### I. Kotlin Style Rules
Project-specific style conventions enforced in code review. These supplement (not replace) `docs/STYLE_AND_BEST_PRACTICES.md`.
**`when` branches with wrapped right-hand side require curly braces.**
When a `when` branch's expression is too long to fit on the same line as the arrow and is wrapped to the next line, wrap the body in `{ }`. A bare `->` followed by an indented expression on its own line is rejected in review.
**Wrong** — wrapped body without braces:
```kotlin
when (type) {
VaultItemCipherType.LOGIN -> VaultAddEditState.ViewState.Content.ItemType.Login()
VaultItemCipherType.BANK_ACCOUNT ->
VaultAddEditState.ViewState.Content.ItemType.BankAccount()
VaultItemCipherType.DRIVERS_LICENSE ->
VaultAddEditState.ViewState.Content.ItemType.DriversLicense()
}
```
**Right** — wrapped body with braces:
```kotlin
when (type) {
VaultItemCipherType.LOGIN -> VaultAddEditState.ViewState.Content.ItemType.Login()
VaultItemCipherType.BANK_ACCOUNT -> {
VaultAddEditState.ViewState.Content.ItemType.BankAccount()
}
VaultItemCipherType.DRIVERS_LICENSE -> {
VaultAddEditState.ViewState.Content.ItemType.DriversLicense()
}
}
```
Single-line branches (body fits on the same line as `->`) do **not** need braces.
---
## Bitwarden-Specific Anti-Patterns
**General anti-patterns are documented in CLAUDE.md.** This section covers violations specific to Bitwarden's State-Action-Event, navigation, and data layer patterns:
@@ -514,3 +478,4 @@ For build, test, and codebase discovery commands, use the **`build-test-verify`*
When pointing to specific code, use: `file_path:line_number`
Example: `ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt` (see `handleAction` method)

View File

@@ -0,0 +1,40 @@
---
name: labeling-android-changes
version: 0.1.0
description: Conventional commit type keywords for PR titles and commit messages. Use when determining the change type for commits or PRs. Triggered by "what type", "label", "change type", "conventional commit", "t: label".
---
# Labeling Changes
PR titles and commit messages must include a conventional commit type keyword. This keyword drives automatic `t:` label assignment via CI (`.github/workflows/sdlc-label-pr.yml`).
## Format
The type keyword appears after the Jira ticket prefix:
```
[PM-XXXXX] <type>: <imperative summary>
```
## Type Keywords
| Type | Label | Use for |
|------|-------|---------|
| `feat` | `t:feature` | New features or functionality |
| `fix` | `t:bug` | Bug fixes |
| `refactor` | `t:tech-debt` | Code restructuring without behavior change |
| `chore` | `t:tech-debt` | Maintenance, cleanup, minor tweaks |
| `test` | `t:tech-debt` | Adding or updating tests |
| `perf` | `t:tech-debt` | Performance improvements |
| `docs` | `t:docs` | Documentation changes |
| `ci` / `build` | `t:ci` | CI/CD and build system changes |
| `deps` | `t:deps` | Dependency updates |
| `llm` | `t:llm` | LLM/Claude configuration changes |
| `breaking` | `t:breaking-change` | Breaking changes requiring migration |
| `misc` | `t:misc` | Changes that do not fit other categories |
## Selecting a Type
Infer the type from the task description and changes made. **If the type cannot be confidently determined, ask the user.**
The CI labeling script matches `<type>:` or `<type>(` in the lowercased PR title, so the keyword must be followed by a colon or parenthesis. CI also accepts additional aliases (e.g., `revert`, `bugfix`, `cleanup`). See `.github/label-pr.json` for the full mapping.

View File

@@ -0,0 +1,37 @@
---
name: perform-android-preflight-checklist
version: 0.1.0
description: Quality gate checklist to run before committing or creating a PR. Use when finishing implementation, checking work quality, or preparing to commit. Triggered by "self review", "check my work", "ready to commit", "done implementing", "review checklist", "quality check".
---
# Self-Review Checklist
Run through this checklist before committing or opening a PR.
## Tests
- [ ] Tests pass with correct flavor: `./gradlew app:testStandardDebugUnitTest`
- [ ] New code has corresponding test coverage
- [ ] Tests for affected modules also pass (`:core:test`, `:data:test`, etc.)
## Code Quality
- [ ] Lint/detekt clean: `./gradlew detekt`
- [ ] No unintended file changes (`git diff` review)
- [ ] KDoc on all new public APIs
- [ ] No TODO comments left behind (or they reference a ticket)
## Security
- [ ] No plaintext keys, tokens, or secrets in code
- [ ] User input validated before processing
- [ ] Sensitive data uses encrypted storage patterns
- [ ] No logging of sensitive data (passwords, keys, tokens)
## Bitwarden Patterns
- [ ] String resources in `:ui` module with typographic quotes
- [ ] Navigation route is `@Serializable` and registered in graph
- [ ] New implementations have Hilt `@Binds` or `@Provides` in a module
- [ ] ViewModel extends `BaseViewModel<S, E, A>` with proper state persistence
- [ ] Async results mapped through internal actions (not direct state updates)
## Files
- [ ] No accidental `.idea/`, build output, or generated files staged
- [ ] No credential files or `.env` files included

View File

@@ -68,8 +68,7 @@ Load reference files only when needed for specific questions:
- **Security questions (comprehensive)** → `docs/ARCHITECTURE.md#security` (full zero-knowledge architecture)
- **Testing questions** → `reference/testing-patterns.md` (unit tests, mocking, null safety)
- **UI questions** → `reference/ui-patterns.md` (Compose patterns, theming)
- **Style questions (project-specific)** → `reference/style-patterns.md` (Kotlin rules enforced in review)
- **Style questions (general)** → `docs/STYLE_AND_BEST_PRACTICES.md`
- **Style questions** → `docs/STYLE_AND_BEST_PRACTICES.md`
## Core Principles

View File

@@ -1,32 +0,0 @@
# Style Patterns Quick Reference
Project-specific Kotlin style rules to catch during code review. These supplement (not replace) `docs/STYLE_AND_BEST_PRACTICES.md`.
## `when` branches with wrapped right-hand side require curly braces
When a `when` branch's expression is too long to fit on the same line as `->` and is wrapped to its own line, the body must be wrapped in `{ }`. A bare `->` followed by an indented expression on the next line should be flagged.
**Flag this:**
```kotlin
when (type) {
VaultItemCipherType.LOGIN -> VaultAddEditState.ViewState.Content.ItemType.Login()
VaultItemCipherType.BANK_ACCOUNT ->
VaultAddEditState.ViewState.Content.ItemType.BankAccount()
}
```
**Accept this:**
```kotlin
when (type) {
VaultItemCipherType.LOGIN -> VaultAddEditState.ViewState.Content.ItemType.Login()
VaultItemCipherType.BANK_ACCOUNT -> {
VaultAddEditState.ViewState.Content.ItemType.BankAccount()
}
}
```
Single-line branches (body fits alongside `->`) do **not** require braces.
**Suggested classification:** SUGGESTED (style consistency, not correctness).

View File

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

View File

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

View File

@@ -40,14 +40,14 @@ jobs:
id: retrieve-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "gh-android"
secrets: "CROWDIN-API-TOKEN"
keyvault: "bitwarden-ci"
secrets: "crowdin-api-token, github-gpg-private-key, github-gpg-private-key-passphrase"
- name: Log out from Azure
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 }}
@@ -59,7 +59,7 @@ jobs:
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2.11.0
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.CROWDIN-API-TOKEN }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}
_CROWDIN_PROJECT_ID: "269690"
with:
config: crowdin.yml
@@ -74,3 +74,5 @@ jobs:
pull_request_title: "Crowdin Pull"
pull_request_body: ":inbox_tray: New translations received!"
pull_request_labels: "automated-pr, t:misc"
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
gpg_passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}

View File

@@ -31,14 +31,14 @@ jobs:
id: retrieve-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "gh-android"
secrets: "CROWDIN-API-TOKEN"
keyvault: "bitwarden-ci"
secrets: "crowdin-api-token"
- name: Upload sources
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2.11.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.CROWDIN-API-TOKEN }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}
_CROWDIN_PROJECT_ID: "269690"
with:
config: crowdin.yml

View File

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

View File

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

3
.gitignore vendored
View File

@@ -36,6 +36,9 @@ user.properties
/app/src/standardRelease/google-services.json
/authenticator/src/google-services.json
# Claude Code outputs
.claude/outputs/
# Python
.python-version
__pycache__/

18
Gemfile
View File

@@ -1,21 +1,21 @@
source 'https://rubygems.org'
source "https://rubygems.org"
ruby File.read(".ruby-version").strip
gem 'fastlane', '2.229.1'
gem 'time', '0.4.2'
gem 'fastlane'
gem 'time'
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)
# Since ruby 3.4.0 these are not included in the standard library
gem 'abbrev', '0.1.2'
gem 'logger', '1.7.0'
gem 'mutex_m', '0.3.0'
gem 'csv', '3.3.5'
gem 'abbrev'
gem 'logger'
gem 'mutex_m'
gem 'csv'
# Since ruby 3.4.1 these are not included in the standard library
gem 'nkf', '0.2.0'
gem 'nkf'
# Starting with Ruby 3.5.0, these are not included in the standard library
gem 'ostruct', '0.6.3'
gem 'ostruct'

View File

@@ -3,13 +3,13 @@ GEM
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.9.0)
addressable (2.8.9)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1241.0)
aws-sdk-core (3.246.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.220.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.2.0)
bigdecimal (4.1.2)
base64 (0.3.0)
bigdecimal (4.0.1)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@@ -72,14 +72,13 @@ GEM
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.1)
fastlane (2.229.1)
fastlane (2.229.0)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2.0)
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
@@ -105,7 +104,6 @@ GEM
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0)
naturally (~> 2.2)
nkf (~> 0.2.0)
optparse (>= 0.1.1, < 1.0.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
@@ -122,7 +120,8 @@ GEM
fastlane-plugin-firebase_app_distribution (0.10.1)
google-apis-firebaseappdistribution_v1 (~> 0.3.0)
google-apis-firebaseappdistribution_v1alpha (~> 0.2.0)
fastlane-sirp (1.1.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
@@ -149,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)
@@ -170,13 +169,13 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.19.4)
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)
@@ -187,7 +186,7 @@ GEM
ostruct (0.6.3)
plist (3.7.2)
public_suffix (7.0.5)
rake (13.4.2)
rake (13.3.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
@@ -206,6 +205,7 @@ GEM
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
@@ -235,15 +235,15 @@ PLATFORMS
ruby
DEPENDENCIES
abbrev (= 0.1.2)
csv (= 3.3.5)
fastlane (= 2.229.1)
abbrev
csv
fastlane
fastlane-plugin-firebase_app_distribution
logger (= 1.7.0)
mutex_m (= 0.3.0)
nkf (= 0.2.0)
ostruct (= 0.6.3)
time (= 0.4.2)
logger
mutex_m
nkf
ostruct
time
RUBY VERSION
ruby 3.4.2p28

View File

@@ -226,7 +226,7 @@ configurations.all {
resolutionStrategy.dependencySubstitution {
if ((userProperties["localSdk"] as String?).toBoolean()) {
substitute(module("com.bitwarden:sdk-android"))
.using(module("com.bitwarden:sdk-android:LOCAL"))
.using(module("com.bitwarden:sdk-android.dev:LOCAL"))
}
}
}

View File

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

View File

@@ -10,7 +10,6 @@
android:required="false" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.CAMERA" />

View File

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

View File

@@ -828,22 +828,6 @@
]
}
},
{
"type": "android",
"info": {
"package_name": "ai.perplexity.comet",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "89:58:A4:05:40:1F:69:F5:B0:FB:54:44:24:74:6C:40:DE:C3:0C:09:1F:40:1F:95:1F:61:3C:48:35:C3:E5:EC"
},
{
"build": "userdebug",
"cert_fingerprint_sha256": "68:75:3A:54:59:93:C1:34:D3:BD:A3:72:2A:30:53:BF:4D:48:AD:23:63:2C:4E:27:8B:B3:BF:C1:FB:F6:52:8C"
}
]
}
},
{
"type": "android",
"info": {

View File

@@ -26,7 +26,6 @@ import androidx.navigation.compose.NavHost
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.bitwarden.ui.platform.util.setHorizonOSAppLayout
import com.bitwarden.ui.platform.util.setupEdgeToEdge
import com.bitwarden.ui.platform.util.validate
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityCompletionManager
@@ -89,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
@@ -120,7 +115,6 @@ class MainActivity : AppCompatActivity() {
sso = ssoLauncher,
webAuthn = webAuthnLauncher,
cookie = cookieLauncher,
premiumCheckout = premiumCheckoutLauncher,
),
) {
ObserveScreenDataEffect(
@@ -213,16 +207,6 @@ class MainActivity : AppCompatActivity() {
.takeIf { it }
?: super.dispatchKeyEvent(event)
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
// resize only one time at the start
if (!mainViewModel.stateFlow.value.hasResizeBeenRequested) {
setHorizonOSAppLayout {
mainViewModel.trySendAction(MainAction.Internal.ResizeHasBeenRequested)
}
}
}
@Composable
private fun SetupEventsEffect(navController: NavController) {
EventsEffect(viewModel = mainViewModel) { event ->

View File

@@ -27,7 +27,6 @@ import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySele
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.billing.util.getPremiumCheckoutCallbackResult
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
@@ -98,7 +97,6 @@ class MainViewModel @Inject constructor(
theme = settingsRepository.appTheme,
isScreenCaptureAllowed = settingsRepository.isScreenCaptureAllowed,
isDynamicColorsEnabled = settingsRepository.isDynamicColorsEnabled,
hasResizeBeenRequested = false,
),
) {
private var specialCircumstance: SpecialCircumstance?
@@ -200,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)
}
}
@@ -223,7 +220,6 @@ class MainViewModel @Inject constructor(
is MainAction.Internal.ThemeUpdate -> handleAppThemeUpdated(action)
is MainAction.Internal.DynamicColorsUpdate -> handleDynamicColorsUpdate(action)
is MainAction.Internal.CookieAcquisitionReady -> handleCookieAcquisitionReady()
is MainAction.Internal.ResizeHasBeenRequested -> handleResizeHasBeenRequested()
}
}
@@ -251,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()
@@ -304,10 +294,6 @@ class MainViewModel @Inject constructor(
sendEvent(MainEvent.NavigateToCookieAcquisition)
}
private fun handleResizeHasBeenRequested() {
mutableStateFlow.update { it.copy(hasResizeBeenRequested = true) }
}
private fun handleFirstIntentReceived(action: MainAction.ReceiveFirstIntent) {
handleIntent(
intent = action.intent,
@@ -412,9 +398,7 @@ class MainViewModel @Inject constructor(
hasPremiumCheckoutCallback -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.PremiumCheckout(
callbackResult = intent.data.getPremiumCheckoutCallbackResult(),
)
SpecialCircumstance.PremiumCheckoutResult
}
hasGeneratorShortcut -> {
@@ -537,7 +521,6 @@ data class MainState(
val theme: AppTheme,
val isScreenCaptureAllowed: Boolean,
val isDynamicColorsEnabled: Boolean,
val hasResizeBeenRequested: Boolean,
) : Parcelable {
/**
* Contains all feature flags that are available to the UI.
@@ -572,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.
*/
@@ -655,11 +631,6 @@ sealed class MainAction {
* should proceed.
*/
data object CookieAcquisitionReady : Internal()
/**
* Indicates that resize has been requested on the Activity
*/
data object ResizeHasBeenRequested : Internal()
}
}

View File

@@ -1,8 +1,5 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk
import com.bitwarden.auth.JitMasterPasswordRegistrationResponse
import com.bitwarden.auth.KeyConnectorRegistrationResult
import com.bitwarden.auth.TdeRegistrationResponse
import com.bitwarden.core.AuthRequestResponse
import com.bitwarden.core.KeyConnectorResponse
import com.bitwarden.core.MasterPasswordPolicyOptions
@@ -15,44 +12,7 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
/**
* Source of authentication information and functionality from the Bitwarden SDK.
*/
@Suppress("TooManyFunctions")
interface AuthSdkSource {
/**
* Enrolls the user to master password unlock.
*/
@Suppress("LongParameterList")
suspend fun postKeysForJitPasswordRegistration(
userId: String,
organizationId: String,
organizationPublicKey: String,
organizationSsoIdentifier: String,
salt: String,
masterPassword: String,
masterPasswordHint: String?,
shouldResetPasswordEnroll: Boolean,
): Result<JitMasterPasswordRegistrationResponse>
/**
* Enrolls the user to key connector unlock.
*/
suspend fun postKeysForKeyConnectorRegistration(
userId: String,
accessToken: String,
keyConnectorUrl: String,
ssoOrganizationIdentifier: String,
): Result<KeyConnectorRegistrationResult>
/**
* Enrolls the user to TDE unlock.
*/
suspend fun postKeysForTdeRegistration(
userId: String,
organizationId: String,
organizationPublicKey: String,
deviceIdentifier: String,
shouldTrustDevice: Boolean,
): Result<TdeRegistrationResponse>
/**
* Gets the data needed to create a new auth request.
*/

View File

@@ -1,10 +1,5 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk
import com.bitwarden.auth.JitMasterPasswordRegistrationRequest
import com.bitwarden.auth.JitMasterPasswordRegistrationResponse
import com.bitwarden.auth.KeyConnectorRegistrationResult
import com.bitwarden.auth.TdeRegistrationRequest
import com.bitwarden.auth.TdeRegistrationResponse
import com.bitwarden.core.AuthRequestResponse
import com.bitwarden.core.FingerprintRequest
import com.bitwarden.core.KeyConnectorResponse
@@ -24,92 +19,33 @@ import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
* Primary implementation of [AuthSdkSource] that serves as a convenience wrapper around a
* [AuthClient].
*/
@Suppress("TooManyFunctions")
class AuthSdkSourceImpl(
sdkClientManager: SdkClientManager,
) : BaseSdkSource(sdkClientManager = sdkClientManager),
AuthSdkSource {
override suspend fun postKeysForJitPasswordRegistration(
userId: String,
organizationId: String,
organizationPublicKey: String,
organizationSsoIdentifier: String,
salt: String,
masterPassword: String,
masterPasswordHint: String?,
shouldResetPasswordEnroll: Boolean,
): Result<JitMasterPasswordRegistrationResponse> = runCatchingWithLogs {
getClient(userId = userId)
.auth()
.registration()
.postKeysForJitPasswordRegistration(
request = JitMasterPasswordRegistrationRequest(
orgId = organizationId,
orgPublicKey = organizationPublicKey,
userId = userId,
organizationSsoIdentifier = organizationSsoIdentifier,
salt = salt,
masterPassword = masterPassword,
masterPasswordHint = masterPasswordHint,
resetPasswordEnroll = shouldResetPasswordEnroll,
),
)
}
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 postKeysForTdeRegistration(
userId: String,
organizationId: String,
organizationPublicKey: String,
deviceIdentifier: String,
shouldTrustDevice: Boolean,
): Result<TdeRegistrationResponse> = runCatchingWithLogs {
getClient(userId = userId)
.auth()
.registration()
.postKeysForTdeRegistration(
request = TdeRegistrationRequest(
orgId = organizationId,
orgPublicKey = organizationPublicKey,
userId = userId,
deviceIdentifier = deviceIdentifier,
trustDevice = shouldTrustDevice,
),
)
}
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(
@@ -118,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(
@@ -138,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(
@@ -167,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(
@@ -184,12 +121,12 @@ class AuthSdkSourceImpl(
passwordStrength: PasswordStrength,
policy: MasterPasswordPolicyOptions,
): Result<Boolean> = runCatchingWithLogs {
useClient {
auth().satisfiesPolicy(
getClient()
.auth()
.satisfiesPolicy(
password = password,
strength = passwordStrength.toUByte(),
policy = policy,
)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -230,10 +230,7 @@ interface AuthRepository :
/**
* Continue the previously halted login attempt.
*/
suspend fun continueKeyConnectorLogin(
orgIdentifier: String,
email: String,
): LoginResult
suspend fun continueKeyConnectorLogin(): LoginResult
/**
* Cancel the previously halted login attempt.
@@ -280,7 +277,7 @@ interface AuthRepository :
email: String,
masterPassword: String,
masterPasswordHint: String?,
emailVerificationToken: String,
emailVerificationToken: String? = null,
shouldCheckDataBreaches: Boolean,
isMasterPasswordStrong: Boolean,
): RegisterResult

View File

@@ -5,7 +5,6 @@ import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.RegisterTdeKeyResponse
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.data.manager.toast.ToastManager
import com.bitwarden.core.data.repository.error.MissingPropertyException
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
@@ -18,19 +17,17 @@ 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
import com.bitwarden.network.model.IdentityTokenAuthModel
import com.bitwarden.network.model.OrganizationAutoEnrollStatusResponseJson
import com.bitwarden.network.model.OrganizationKeysResponseJson
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.PasswordHintResponseJson
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.PrevalidateSsoResponseJson
import com.bitwarden.network.model.RefreshTokenResponseJson
import com.bitwarden.network.model.RegisterFinishRequestJson
import com.bitwarden.network.model.RegisterRequestJson
import com.bitwarden.network.model.RegisterResponseJson
import com.bitwarden.network.model.ResendEmailRequestJson
import com.bitwarden.network.model.ResendNewDeviceOtpRequestJson
@@ -102,11 +99,8 @@ import com.x8bit.bitwarden.data.auth.repository.util.CookieCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
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.accountKeysJson
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.privateKey
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
@@ -119,7 +113,6 @@ import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
@@ -130,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
@@ -150,7 +144,6 @@ import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.time.Clock
import javax.inject.Singleton
@@ -184,10 +177,9 @@ class AuthRepositoryImpl(
private val userStateManager: UserStateManager,
private val kdfManager: KdfManager,
private val toastManager: ToastManager,
private val featureFlagManager: FeatureFlagManager,
logsManager: LogsManager,
pushManager: PushManager,
private val dispatcherManager: DispatcherManager,
dispatcherManager: DispatcherManager,
) : AuthRepository,
AuthRequestManager by authRequestManager,
BiometricsEncryptionManager by biometricsEncryptionManager,
@@ -469,140 +461,84 @@ class AuthRepositoryImpl(
?: return NewSsoUserResult.Failure(error = NoActiveUserException())
val orgIdentifier = rememberedOrgIdentifier
?: return NewSsoUserResult.Failure(error = MissingPropertyException("OrgIdentifier"))
return userStateManager.userStateTransaction {
organizationService
.getOrganizationAutoEnrollStatus(organizationIdentifier = orgIdentifier)
.flatMap { orgAutoEnrollStatus ->
organizationService
.getOrganizationKeys(organizationId = orgAutoEnrollStatus.organizationId)
.flatMap { organizationKeys ->
if (featureFlagManager.getFeatureFlag(FlagKey.V2EncryptionTde)) {
registerUserForTdeV2(
profile = account.profile,
orgAutoEnrollStatus = orgAutoEnrollStatus,
orgKeys = organizationKeys,
)
} else {
registerUserForTdeV1(
profile = account.profile,
orgAutoEnrollStatus = orgAutoEnrollStatus,
orgKeys = organizationKeys,
)
}
}
}
.fold(
onSuccess = { NewSsoUserResult.Success },
onFailure = { NewSsoUserResult.Failure(error = it) },
)
}
}
private suspend fun registerUserForTdeV1(
profile: AccountJson.Profile,
orgAutoEnrollStatus: OrganizationAutoEnrollStatusResponseJson,
orgKeys: OrganizationKeysResponseJson,
): Result<Pair<RegisterTdeKeyResponse, CreateAccountKeysResponseJson>> {
val userId = profile.userId
return authSdkSource
.makeRegisterTdeKeysAndUnlockVault(
userId = userId,
email = profile.email,
orgPublicKey = orgKeys.publicKey,
rememberDevice = authDiskSource.getShouldTrustDevice(userId = userId) == true,
)
.flatMap { registerTdeKeyResponse ->
accountsService
.createAccountKeys(
publicKey = registerTdeKeyResponse.publicKey,
encryptedPrivateKey = registerTdeKeyResponse.privateKey,
)
.map { createAccountKeysResponse ->
registerTdeKeyResponse to createAccountKeysResponse
}
}
.flatMap { (registerTdeKeyResponse, createAccountKeysResponse) ->
val userId = account.profile.userId
return organizationService
.getOrganizationAutoEnrollStatus(orgIdentifier)
.flatMap { orgAutoEnrollStatus ->
organizationService
.organizationResetPasswordEnroll(
organizationId = orgAutoEnrollStatus.organizationId,
userId = userId,
passwordHash = null,
resetPasswordKey = registerTdeKeyResponse.adminReset,
)
.map { registerTdeKeyResponse to createAccountKeysResponse }
}
.onSuccess { (registerTdeKeyResponse, createAccountKeysResponse) ->
authDiskSource.storeAccountKeys(
userId = userId,
accountKeys = createAccountKeysResponse.accountKeys,
)
// TDE and SSO user creation still uses crypto-v1. These users are not expected to
// have the AEAD keys so we only store the private key for now.
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
// for more details.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = registerTdeKeyResponse.privateKey,
)
vaultRepository.syncVaultState(userId = userId)
registerTdeKeyResponse.deviceKey?.let { response ->
trustedDeviceManager.trustThisDevice(
userId = userId,
trustDeviceResponse = response,
)
}
}
}
private suspend fun registerUserForTdeV2(
profile: AccountJson.Profile,
orgAutoEnrollStatus: OrganizationAutoEnrollStatusResponseJson,
orgKeys: OrganizationKeysResponseJson,
): Result<VaultUnlockResult> {
val userId = profile.userId
val shouldTrustDevice = authDiskSource.getShouldTrustDevice(userId = userId) == true
return withContext(dispatcherManager.io) {
authSdkSource.postKeysForTdeRegistration(
userId = userId,
organizationId = orgAutoEnrollStatus.organizationId,
organizationPublicKey = orgKeys.publicKey,
deviceIdentifier = authDiskSource.uniqueAppId,
shouldTrustDevice = shouldTrustDevice,
)
}
.map { response ->
// Clear the 'should trust device' flag, since the SDK trusted the device above.
authDiskSource.storeShouldTrustDevice(userId = userId, shouldTrustDevice = null)
this
.unlockVault(
accountCryptographicState = response.accountCryptographicState,
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = response.userKey,
),
)
.also { result ->
if (result is VaultUnlockResult.Success) {
authDiskSource.storeAccountKeys(
userId = userId,
accountKeys = response.accountCryptographicState.accountKeysJson,
.getOrganizationKeys(orgAutoEnrollStatus.organizationId)
.flatMap { organizationKeys ->
authSdkSource.makeRegisterTdeKeysAndUnlockVault(
userId = userId,
email = account.profile.email,
orgPublicKey = organizationKeys.publicKey,
rememberDevice = authDiskSource
.getShouldTrustDevice(userId = userId) == true,
)
}
.flatMap { registerTdeKeyResponse ->
accountsService
.createAccountKeys(
publicKey = registerTdeKeyResponse.publicKey,
encryptedPrivateKey = registerTdeKeyResponse.privateKey,
)
// Storing the private key here for legacy purposes, the
// `accountKeysJson` stored above will be used for most purposes.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = response.accountCryptographicState.privateKey,
)
if (shouldTrustDevice) {
authDiskSource.storeDeviceKey(
userId = userId,
deviceKey = response.deviceKey,
)
.map { createAccountKeysResponse ->
registerTdeKeyResponse to createAccountKeysResponse
}
}
}
.flatMap { (registerTdeKeyResponse, createAccountKeysResponse) ->
organizationService
.organizationResetPasswordEnroll(
organizationId = orgAutoEnrollStatus.organizationId,
userId = userId,
passwordHash = null,
resetPasswordKey = registerTdeKeyResponse.adminReset,
)
.map { registerTdeKeyResponse to createAccountKeysResponse }
}
.onSuccess { (registerTdeKeyResponse, createAccountKeysResponse) ->
createNewSsoUserSuccess(
userId = userId,
createAccountKeysResponse = createAccountKeysResponse,
registerTdeKeyResponse = registerTdeKeyResponse,
)
}
}
.fold(
onSuccess = { NewSsoUserResult.Success },
onFailure = { NewSsoUserResult.Failure(error = it) },
)
}
/**
* Stores all the relevant data from a successful creation of an SSO user. The data is stored
* while in an [UserStateManager.userStateTransaction] to ensure the `UserState` is only
* updated once after data stored.
*/
private suspend fun createNewSsoUserSuccess(
userId: String,
createAccountKeysResponse: CreateAccountKeysResponseJson,
registerTdeKeyResponse: RegisterTdeKeyResponse,
): Unit = userStateManager.userStateTransaction {
authDiskSource.storeAccountKeys(
userId = userId,
accountKeys = createAccountKeysResponse.accountKeys,
)
// TDE and SSO user creation still uses crypto-v1. These users are not
// expected to have the AEAD keys so we only store the private key for now.
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
// for more details.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = registerTdeKeyResponse.privateKey,
)
vaultRepository.syncVaultState(userId = userId)
registerTdeKeyResponse.deviceKey?.let { trustDeviceResponse ->
trustedDeviceManager.trustThisDevice(
userId = userId,
trustDeviceResponse = trustDeviceResponse,
)
}
}
override suspend fun completeTdeLogin(
@@ -619,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 ->
@@ -626,8 +565,11 @@ class AuthRepositoryImpl(
},
) {
unlockVault(
accountCryptographicState = accountKeys.toAccountCryptographicState(
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
signedPublicKey = signedPublicKey,
),
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
@@ -739,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,
@@ -946,11 +885,12 @@ class AuthRepositoryImpl(
return SwitchAccountResult.AccountSwitched
}
@Suppress("LongMethod")
override suspend fun register(
email: String,
masterPassword: String,
masterPasswordHint: String?,
emailVerificationToken: String,
emailVerificationToken: String?,
shouldCheckDataBreaches: Boolean,
isMasterPasswordStrong: Boolean,
): RegisterResult {
@@ -978,21 +918,39 @@ class AuthRepositoryImpl(
kdf = kdf,
)
.flatMap { registerKeyResponse ->
identityService.registerFinish(
body = RegisterFinishRequestJson(
email = email,
masterPasswordHash = registerKeyResponse.masterPasswordHash,
masterPasswordHint = masterPasswordHint,
emailVerificationToken = emailVerificationToken,
userSymmetricKey = registerKeyResponse.encryptedUserKey,
userAsymmetricKeys = RegisterFinishRequestJson.Keys(
publicKey = registerKeyResponse.keys.public,
encryptedPrivateKey = registerKeyResponse.keys.private,
if (emailVerificationToken == null) {
// TODO PM-6675: Remove register call and service implementation
identityService.register(
body = RegisterRequestJson(
email = email,
masterPasswordHash = registerKeyResponse.masterPasswordHash,
masterPasswordHint = masterPasswordHint,
key = registerKeyResponse.encryptedUserKey,
keys = RegisterRequestJson.Keys(
publicKey = registerKeyResponse.keys.public,
encryptedPrivateKey = registerKeyResponse.keys.private,
),
kdfType = kdf.toKdfTypeJson(),
kdfIterations = kdf.iterations,
),
kdfType = kdf.toKdfTypeJson(),
kdfIterations = kdf.iterations,
),
)
)
} else {
identityService.registerFinish(
body = RegisterFinishRequestJson(
email = email,
masterPasswordHash = registerKeyResponse.masterPasswordHash,
masterPasswordHint = masterPasswordHint,
emailVerificationToken = emailVerificationToken,
userSymmetricKey = registerKeyResponse.encryptedUserKey,
userAsymmetricKeys = RegisterFinishRequestJson.Keys(
publicKey = registerKeyResponse.keys.public,
encryptedPrivateKey = registerKeyResponse.keys.private,
),
kdfType = kdf.toKdfTypeJson(),
kdfIterations = kdf.iterations,
),
)
}
}
.fold(
onSuccess = {
@@ -1149,73 +1107,85 @@ class AuthRepositoryImpl(
)
}
@Suppress("LongMethod")
override suspend fun setPassword(
organizationIdentifier: String,
password: String,
passwordHint: String?,
): SetPasswordResult {
val profile = authDiskSource.userState?.activeAccount?.profile
val activeAccount = authDiskSource
.userState
?.activeAccount
?: return SetPasswordResult.Error(error = NoActiveUserException())
return when (profile.forcePasswordResetReason) {
val userId = activeAccount.profile.userId
// Update the saved master password hash.
val passwordHash = authSdkSource
.hashPassword(
email = activeAccount.profile.email,
password = password,
kdf = activeAccount.profile.toSdkParams(),
purpose = HashPurpose.SERVER_AUTHORIZATION,
)
.getOrElse { return@setPassword SetPasswordResult.Error(error = it) }
return when (activeAccount.profile.forcePasswordResetReason) {
ForcePasswordResetReason.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION -> {
setUpdatedPassword(
profile = profile,
organizationIdentifier = organizationIdentifier,
password = password,
passwordHint = passwordHint,
)
vaultSdkSource
.updatePassword(userId = userId, newPassword = password)
.map { it.newKey to null }
}
ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET,
ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
null,
-> {
setPasswordForJit(
profile = profile,
organizationIdentifier = organizationIdentifier,
password = password,
passwordHint = passwordHint,
)
authSdkSource
.makeRegisterKeys(
email = activeAccount.profile.email,
password = password,
kdf = activeAccount.profile.toSdkParams(),
)
.map { it.encryptedUserKey to it.keys }
}
}
}
private suspend fun setUpdatedPassword(
profile: AccountJson.Profile,
organizationIdentifier: String,
password: String,
passwordHint: String?,
): SetPasswordResult {
val userId = profile.userId
return vaultSdkSource
.updatePassword(userId = userId, newPassword = password)
.flatMap { response ->
.flatMap { (encryptedUserKey, rsaKeys) ->
accountsService
.setPassword(
body = SetPasswordRequestJson(
passwordHash = response.passwordHash,
passwordHash = passwordHash,
passwordHint = passwordHint,
organizationIdentifier = organizationIdentifier,
kdfIterations = profile.kdfIterations,
kdfMemory = profile.kdfMemory,
kdfParallelism = profile.kdfParallelism,
kdfType = profile.kdfType,
key = response.newKey,
keys = null,
kdfIterations = activeAccount.profile.kdfIterations,
kdfMemory = activeAccount.profile.kdfMemory,
kdfParallelism = activeAccount.profile.kdfParallelism,
kdfType = activeAccount.profile.kdfType,
key = encryptedUserKey,
keys = rsaKeys?.let {
RegisterRequestJson.Keys(
publicKey = it.public,
encryptedPrivateKey = it.private,
)
},
),
)
.onSuccess {
authDiskSource.storeUserKey(userId = userId, userKey = response.newKey)
rsaKeys?.private?.let {
// This process is used by TDE and Enterprise accounts during initial
// login. We continue to store the locally generated keys
// until TDE and Enterprise accounts support AEAD keys.
authDiskSource.storePrivateKey(userId = userId, privateKey = it)
}
authDiskSource.storeUserKey(userId = userId, userKey = encryptedUserKey)
}
.map { response.passwordHash }
}
.flatMap { masterPasswordHash ->
.flatMap {
when (val result = vaultRepository.unlockVaultWithMasterPassword(password)) {
is VaultUnlockResult.Success -> {
enrollUserInPasswordReset(
userId = userId,
organizationIdentifier = organizationIdentifier,
passwordHash = masterPasswordHash,
passwordHash = passwordHash,
)
}
@@ -1226,155 +1196,8 @@ class AuthRepositoryImpl(
}
}
.onSuccess {
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword(
masterPasswordUnlock = null,
)
this.organizationIdentifier = null
}
.fold(
onFailure = { SetPasswordResult.Error(error = it) },
onSuccess = { SetPasswordResult.Success },
)
}
private suspend fun setPasswordForJit(
profile: AccountJson.Profile,
organizationIdentifier: String,
password: String,
passwordHint: String?,
): SetPasswordResult {
if (!featureFlagManager.getFeatureFlag(FlagKey.V2EncryptionJitPassword)) {
return setPasswordForJitV1(
profile = profile,
organizationIdentifier = organizationIdentifier,
password = password,
passwordHint = passwordHint,
)
}
val userId = profile.userId
return organizationService
.getOrganizationAutoEnrollStatus(organizationIdentifier = organizationIdentifier)
.flatMap { enrollStatus ->
organizationService
.getOrganizationKeys(organizationId = enrollStatus.organizationId)
.map { orgKeys -> enrollStatus to orgKeys }
}
.flatMap { (enrollStatus, orgKeys) ->
withContext(dispatcherManager.io) {
authSdkSource.postKeysForJitPasswordRegistration(
userId = userId,
organizationId = enrollStatus.organizationId,
organizationPublicKey = orgKeys.publicKey,
organizationSsoIdentifier = organizationIdentifier,
salt = profile.email,
masterPassword = password,
masterPasswordHint = passwordHint,
shouldResetPasswordEnroll = enrollStatus.isResetPasswordEnabled,
)
}
}
.onSuccess { response ->
authDiskSource.storeAccountKeys(
userId = userId,
accountKeys = response.accountCryptographicState.accountKeysJson,
)
// TDE and SSO user creation still uses crypto-v1. These users are not
// expected to have the AEAD keys so we only store the private key for now.
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
// for more details.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = response.accountCryptographicState.privateKey,
)
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword(
masterPasswordUnlock = response.masterPasswordUnlock,
)
this.organizationIdentifier = null
}
.flatMap { response ->
// Logging in with the password instead of the decrypted userKey will store
// the master password hash automatically.
when (val result = vaultRepository.unlockVaultWithMasterPassword(password)) {
VaultUnlockResult.Success -> response.asSuccess()
is VaultUnlockError -> {
(result.error ?: IllegalStateException("Failed to unlock vault"))
.asFailure()
}
}
}
.fold(
onFailure = { SetPasswordResult.Error(error = it) },
onSuccess = { SetPasswordResult.Success },
)
}
@Suppress("LongMethod")
private suspend fun setPasswordForJitV1(
profile: AccountJson.Profile,
organizationIdentifier: String,
password: String,
passwordHint: String?,
): SetPasswordResult {
val userId = profile.userId
return authSdkSource
.makeRegisterKeys(
email = profile.email,
password = password,
kdf = profile.toSdkParams(),
)
.flatMap { response ->
accountsService
.setPassword(
body = SetPasswordRequestJson(
passwordHash = response.masterPasswordHash,
passwordHint = passwordHint,
organizationIdentifier = organizationIdentifier,
kdfIterations = profile.kdfIterations,
kdfMemory = profile.kdfMemory,
kdfParallelism = profile.kdfParallelism,
kdfType = profile.kdfType,
key = response.encryptedUserKey,
keys = SetPasswordRequestJson.Keys(
publicKey = response.keys.public,
encryptedPrivateKey = response.keys.private,
),
),
)
.onSuccess {
// This process is used by TDE and Enterprise accounts during initial
// login. We continue to store the locally generated keys
// until TDE and Enterprise accounts support AEAD keys.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = response.keys.private,
)
authDiskSource.storeUserKey(
userId = userId,
userKey = response.encryptedUserKey,
)
}
.map { response.masterPasswordHash }
}
.flatMap { masterPasswordHash ->
when (val result = vaultRepository.unlockVaultWithMasterPassword(password)) {
is VaultUnlockResult.Success -> {
enrollUserInPasswordReset(
userId = userId,
organizationIdentifier = organizationIdentifier,
passwordHash = masterPasswordHash,
)
}
is VaultUnlockError -> {
(result.error ?: IllegalStateException("Failed to unlock vault"))
.asFailure()
}
}
}
.onSuccess {
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword(
masterPasswordUnlock = null,
)
authDiskSource.storeMasterPasswordHash(userId = userId, passwordHash = passwordHash)
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword()
this.organizationIdentifier = null
}
.fold(
@@ -1853,7 +1676,6 @@ class AuthRepositoryImpl(
checkForVaultUnlockError(
onVaultUnlockError = { vaultUnlockError ->
authDiskSource.storeAccountTokens(userId = profile.userId, accountTokens = null)
return@userStateTransaction vaultUnlockError.toLoginErrorResult()
},
) {
@@ -1876,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,
@@ -1951,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)
@@ -2041,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.
@@ -2055,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,
@@ -2071,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,
@@ -2085,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
@@ -2147,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,
@@ -2171,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,
@@ -2199,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,
)
}
}
}
@@ -2218,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
@@ -2235,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(
@@ -2264,8 +2146,11 @@ class AuthRepositoryImpl(
}
vaultUnlockResult = unlockVault(
accountCryptographicState = accountKeys.toAccountCryptographicState(
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
signedPublicKey = signedPublicKey,
),
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.DeviceKey(

View File

@@ -21,7 +21,6 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
@@ -74,7 +73,6 @@ object AuthRepositoryModule {
userStateManager: UserStateManager,
kdfManager: KdfManager,
toastManager: ToastManager,
featureFlagManager: FeatureFlagManager,
): AuthRepository = AuthRepositoryImpl(
clock = clock,
accountsService = accountsService,
@@ -102,7 +100,6 @@ object AuthRepositoryModule {
userStateManager = userStateManager,
kdfManager = kdfManager,
toastManager = toastManager,
featureFlagManager = featureFlagManager,
)
@Provides

View File

@@ -15,6 +15,10 @@ import com.bitwarden.network.model.OrganizationType
* @property userIsClaimedByOrganization Indicates that the user is claimed by the organization.
* @property limitItemDeletion Indicates that the organization limits item deletion.
* @property shouldUseEvents Indicates if the organization uses tracking events.
* @property maxCollections The maximum number of collections allowed (nullable).
* @property canCreateNewCollections Indicates if the user can create new collections.
* @property canEditAnyCollection Indicates if the user can edit any collection.
* @property canDeleteAnyCollection Indicates if the user can delete any collection.
*/
data class Organization(
val id: String,
@@ -26,4 +30,22 @@ data class Organization(
val userIsClaimedByOrganization: Boolean,
val limitItemDeletion: Boolean,
val shouldUseEvents: Boolean,
)
val maxCollections: Int?,
val limitCollectionCreation: Boolean,
val limitCollectionDeletion: Boolean,
val organizationUserId: String?,
val canCreateNewCollections: Boolean,
val canEditAnyCollection: Boolean,
val canDeleteAnyCollection: Boolean,
) {
/**
* Whether the user can create new collections in this organization, accounting for
* the organization's role and limitCollectionCreation setting.
* Matches web client logic: `!limitCollectionCreation || isAdmin || permissions.createNewCollections`
*/
val canManageCollections: Boolean
get() = !limitCollectionCreation ||
role == OrganizationType.ADMIN ||
role == OrganizationType.OWNER ||
canCreateNewCollections
}

View File

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

View File

@@ -28,6 +28,13 @@ fun SyncResponseJson.Profile.Organization.toOrganization(): Organization? =
userIsClaimedByOrganization = this.userIsClaimedByOrganization,
limitItemDeletion = this.limitItemDeletion,
shouldUseEvents = this.shouldUseEvents,
maxCollections = this.maxCollections,
organizationUserId = this.organizationUserId,
limitCollectionCreation = this.limitCollectionCreation,
limitCollectionDeletion = this.limitCollectionDeletion,
canCreateNewCollections = this.permissions.canCreateNewCollections,
canEditAnyCollection = this.permissions.canEditAnyCollection,
canDeleteAnyCollection = this.permissions.canDeleteAnyCollection,
)
}

View File

@@ -1,9 +1,7 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.bitwarden.core.MasterPasswordUnlockData
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.MasterPasswordUnlockDataJson
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
@@ -11,7 +9,6 @@ import com.bitwarden.network.model.UserDecryptionOptionsJson
import com.bitwarden.ui.platform.base.util.toHexColorRepresentation
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfRequestModel
import com.x8bit.bitwarden.data.auth.repository.model.UserAccountTokens
import com.x8bit.bitwarden.data.auth.repository.model.UserKeyConnectorState
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
@@ -61,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 ->
@@ -87,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
@@ -111,34 +99,20 @@ fun UserStateJson.toUpdatedUserStateJson(
* Updates the [UserStateJson] to set the `hasMasterPassword` value to `true` after a user sets
* their password.
*/
fun UserStateJson.toUserStateJsonWithPassword(
masterPasswordUnlock: MasterPasswordUnlockData?,
): UserStateJson {
fun UserStateJson.toUserStateJsonWithPassword(): UserStateJson {
val account = this.activeAccount
val profile = account.profile
val userDecryptionOptions = profile.userDecryptionOptions
val masterPasswordUnlockJson = masterPasswordUnlock
?.let {
MasterPasswordUnlockDataJson(
salt = it.salt,
kdf = it.kdf.toKdfRequestModel(),
masterKeyWrappedUserKey = it.masterKeyWrappedUserKey,
)
}
?: userDecryptionOptions?.masterPasswordUnlock
val updatedProfile = profile
.copy(
forcePasswordResetReason = null,
userDecryptionOptions = userDecryptionOptions
?.copy(
hasMasterPassword = true,
masterPasswordUnlock = masterPasswordUnlockJson,
)
userDecryptionOptions = profile
.userDecryptionOptions
?.copy(hasMasterPassword = true)
?: UserDecryptionOptionsJson(
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = null,
trustedDeviceUserDecryptionOptions = null,
masterPasswordUnlock = masterPasswordUnlockJson,
masterPasswordUnlock = null,
),
)
val updatedAccount = account.copy(profile = updatedProfile)

View File

@@ -1,41 +0,0 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.bitwarden.core.WrappedAccountCryptographicState
import com.bitwarden.network.model.AccountKeysJson
import com.bitwarden.network.model.AccountKeysJson.PublicKeyEncryptionKeyPair
import com.bitwarden.network.model.AccountKeysJson.SecurityState
import com.bitwarden.network.model.AccountKeysJson.SignatureKeyPair
/**
* The user's encryption private key, wrapped by the user key.
*/
val WrappedAccountCryptographicState.privateKey: String
get() = when (this) {
is WrappedAccountCryptographicState.V1 -> this.privateKey
is WrappedAccountCryptographicState.V2 -> this.privateKey
}
/**
* Converts the [WrappedAccountCryptographicState] into a [AccountKeysJson].
*
* @receiver `WrappedAccountCryptographicState` to convert to `AccountEncryptionKeysJson`.
*/
val WrappedAccountCryptographicState.accountKeysJson: AccountKeysJson?
get() = when (this) {
is WrappedAccountCryptographicState.V1 -> null
is WrappedAccountCryptographicState.V2 -> AccountKeysJson(
publicKeyEncryptionKeyPair = PublicKeyEncryptionKeyPair(
publicKey = "",
signedPublicKey = this.signedPublicKey,
wrappedPrivateKey = this.privateKey,
),
signatureKeyPair = SignatureKeyPair(
wrappedSigningKey = this.signingKey,
verifyingKey = "",
),
securityState = SecurityState(
securityState = this.securityState,
securityVersion = 2,
),
)
}

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,6 @@ import com.x8bit.bitwarden.data.billing.repository.BillingRepository
import com.x8bit.bitwarden.data.billing.repository.BillingRepositoryImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.Module
import dagger.Provides
@@ -59,7 +58,6 @@ object BillingModule {
settingsDiskSource: SettingsDiskSource,
vaultRepository: VaultRepository,
featureFlagManager: FeatureFlagManager,
pushManager: PushManager,
clock: Clock,
dispatcherManager: DispatcherManager,
): PremiumStateManager = PremiumStateManagerImpl(
@@ -69,7 +67,6 @@ object BillingModule {
settingsDiskSource = settingsDiskSource,
vaultRepository = vaultRepository,
featureFlagManager = featureFlagManager,
pushManager = pushManager,
clock = clock,
dispatcherManager = dispatcherManager,
)

View File

@@ -3,13 +3,10 @@ package com.x8bit.bitwarden.data.billing.manager
import kotlinx.coroutines.flow.StateFlow
/**
* URL opened when the user taps the "Learn more" CTA on the "Upgraded to Premium" action card.
*/
const val UPGRADED_TO_PREMIUM_LEARN_MORE_URL: String =
"https://bitwarden.com/help/password-manager-plans/"
/**
* 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 {
@@ -19,27 +16,8 @@ interface PremiumStateManager {
*/
val isPremiumUpgradeBannerEligibleFlow: StateFlow<Boolean>
/**
* Emits `true` while the active user is eligible to see the "Upgraded to Premium" action
* card and `false` otherwise. Eligibility persists across app launches until the user
* consumes the card via [dismissUpgradedToPremiumCard].
*/
val isUpgradedToPremiumCardEligibleFlow: 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.
*/
fun dismissPremiumUpgradeBanner()
/**
* Marks the "Upgraded to Premium" action card as consumed for the current user. This is
* called for both the dismiss (X) and Learn more interactions — once consumed, the card
* never re-appears for that user.
*/
fun dismissUpgradedToPremiumCard()
}

View File

@@ -9,9 +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.manager.PushManager
import com.x8bit.bitwarden.data.platform.util.isActive
import com.x8bit.bitwarden.data.platform.util.scanPairs
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import kotlinx.coroutines.CoroutineScope
@@ -19,12 +16,9 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import java.time.Clock
import java.time.Duration
@@ -32,16 +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,
pushManager: PushManager,
featureFlagManager: FeatureFlagManager,
private val clock: Clock,
dispatcherManager: DispatcherManager,
) : PremiumStateManager {
@@ -65,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
@@ -94,71 +88,6 @@ class PremiumStateManagerImpl(
initialValue = false,
)
@OptIn(ExperimentalCoroutinesApi::class)
override val isUpgradedToPremiumCardEligibleFlow: StateFlow<Boolean> =
authDiskSource
.activeUserIdChangesFlow
.flatMapLatest { userId ->
if (userId == null) {
flowOf(false)
} else {
combine(
settingsDiskSource
.getUpgradedToPremiumCardPendingFlow(userId)
.map { it ?: false },
settingsDiskSource
.getUpgradedToPremiumCardConsumedFlow(userId)
.map { it ?: false },
) { isPending, isConsumed -> isPending && !isConsumed }
}
}
.distinctUntilChanged()
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = false,
)
init {
// Personal premium upgrade signaled via push notification (standard flavor only). The
// server emits PREMIUM_STATUS_CHANGED with the user's personal isPremium flag.
pushManager
.premiumStatusChangedFlow
.onEach { data ->
if (data.isPremium) {
markUpgradedToPremiumCardPending(userId = data.userId)
}
}
.launchIn(unconfinedScope)
// Sync-delta detection: observe the active user's premium flag transitioning false → true
// (e.g., F-Droid users without push support). NOTE: UserState.Account.isPremium is
// derived from `hasPremium = isPremium || isPremiumFromOrganization` so this path may
// also fire for organization-granted premium. The push path (above) is personal-only and
// takes precedence on flavors that support it.
authRepository
.userStateFlow
.map { state ->
state?.activeAccount?.let { it.userId to it.isPremium }
}
.distinctUntilChanged()
.scanPairs()
.onEach { (previous, current) ->
if (current == null) return@onEach
val (currentUserId, currentIsPremium) = current
if (!currentIsPremium) return@onEach
// Same user transitioning from non-premium to premium counts as an upgrade.
if (previous?.first == currentUserId && !previous.second) {
markUpgradedToPremiumCardPending(userId = currentUserId)
}
}
.launchIn(unconfinedScope)
}
override fun isInAppUpgradeAvailable(): Boolean =
billingRepository.isInAppBillingSupportedFlow.value &&
featureFlagManager.getFeatureFlag(FlagKey.MobilePremiumUpgrade)
override fun dismissPremiumUpgradeBanner() {
val activeUserId = authDiskSource.userState?.activeUserId ?: return
settingsDiskSource.storePremiumUpgradeBannerDismissed(
@@ -166,29 +95,6 @@ class PremiumStateManagerImpl(
isDismissed = true,
)
}
override fun dismissUpgradedToPremiumCard() {
val activeUserId = authDiskSource.userState?.activeUserId ?: return
settingsDiskSource.storeUpgradedToPremiumCardConsumed(
userId = activeUserId,
isConsumed = true,
)
settingsDiskSource.storeUpgradedToPremiumCardPending(
userId = activeUserId,
isPending = false,
)
}
private fun markUpgradedToPremiumCardPending(userId: String) {
// Don't re-arm the card if the user has already consumed it for this account.
if (settingsDiskSource.getUpgradedToPremiumCardConsumed(userId = userId) == true) {
return
}
settingsDiskSource.storeUpgradedToPremiumCardPending(
userId = userId,
isPending = true,
)
}
}
/**
@@ -210,7 +116,7 @@ private fun DataState<VaultData>.activeVaultItemCount(): Int =
data
?.decryptCipherListResult
?.successes
?.count { it.isActive }
?.count { it.deletedDate == null && it.archivedDate == null }
?: 0
private const val PREMIUM_UPGRADE_MINIMUM_VAULT_ITEMS: Int = 5

View File

@@ -2,8 +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 com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult
import kotlinx.coroutines.flow.StateFlow
/**
@@ -25,14 +23,4 @@ interface BillingRepository {
* 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
/**
* Fetches the current user's premium subscription details.
*/
suspend fun getSubscription(): SubscriptionResult
}

View File

@@ -4,9 +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 com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult
import com.x8bit.bitwarden.data.billing.repository.util.toSubscriptionInfo
import kotlinx.coroutines.flow.StateFlow
/**
@@ -35,30 +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)
},
)
override suspend fun getSubscription(): SubscriptionResult =
billingService
.getSubscription()
.fold(
onSuccess = {
SubscriptionResult.Success(
subscription = it.toSubscriptionInfo(),
)
},
onFailure = { SubscriptionResult.Error(error = it) },
)
}

View File

@@ -1,9 +0,0 @@
package com.x8bit.bitwarden.data.billing.repository.model
/**
* The billing cadence of a premium subscription.
*/
enum class PlanCadence {
ANNUALLY,
MONTHLY,
}

View File

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

View File

@@ -1,12 +0,0 @@
package com.x8bit.bitwarden.data.billing.repository.model
/**
* Represents the UI-facing subscription status for premium users.
*/
enum class PremiumSubscriptionStatus {
ACTIVE,
CANCELED,
OVERDUE_PAYMENT,
PAST_DUE,
PAUSED,
}

View File

@@ -1,36 +0,0 @@
package com.x8bit.bitwarden.data.billing.repository.model
import java.math.BigDecimal
import java.time.Instant
/**
* Domain model containing a premium subscription's billing and lifecycle details.
*
* @property status The UI-facing subscription status.
* @property cadence The billing cadence (annual or monthly).
* @property seatsCost The cost of the seat line item for the current cadence.
* @property storageCost The cost of additional storage, or null if none.
* @property discountAmount The money value of any applied discount, or null if no discount is
* present. Percent-off discounts are resolved against the password manager subtotal at mapping
* time.
* @property estimatedTax The estimated tax charged on the next invoice.
* @property nextChargeTotal The total of the next invoice:
* `seatsCost + (storageCost ?: 0) - (discountAmount ?: 0) + estimatedTax`.
* @property nextCharge The date of the next charge, or null if not applicable.
* @property canceledDate The date the subscription was canceled, or null.
* @property suspensionDate The date the subscription will be suspended, or null.
* @property gracePeriodDays The grace period in days, or null.
*/
data class SubscriptionInfo(
val status: PremiumSubscriptionStatus,
val cadence: PlanCadence,
val seatsCost: BigDecimal,
val storageCost: BigDecimal?,
val discountAmount: BigDecimal?,
val estimatedTax: BigDecimal,
val nextChargeTotal: BigDecimal,
val nextCharge: Instant?,
val canceledDate: Instant?,
val suspensionDate: Instant?,
val gracePeriodDays: Int?,
)

View File

@@ -1,20 +0,0 @@
package com.x8bit.bitwarden.data.billing.repository.model
/**
* Models the result of fetching the user's premium subscription details.
*/
sealed class SubscriptionResult {
/**
* Subscription details were fetched successfully.
*/
data class Success(
val subscription: SubscriptionInfo,
) : SubscriptionResult()
/**
* An error occurred while fetching subscription details.
*/
data class Error(
val error: Throwable,
) : SubscriptionResult()
}

View File

@@ -1,85 +0,0 @@
package com.x8bit.bitwarden.data.billing.repository.util
import com.bitwarden.network.model.BitwardenDiscountJson
import com.bitwarden.network.model.BitwardenSubscriptionResponseJson
import com.bitwarden.network.model.CadenceTypeJson
import com.bitwarden.network.model.DiscountTypeJson
import com.bitwarden.network.model.SubscriptionStatusJson
import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence
import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionInfo
import java.math.BigDecimal
import java.math.RoundingMode
private val PERCENT_DIVISOR: BigDecimal = BigDecimal("100")
private const val MONEY_SCALE: Int = 2
/**
* Maps a [BitwardenSubscriptionResponseJson] into a [SubscriptionInfo] domain
* model.
*
* `discountAmount` is resolved at mapping time: fixed-amount discounts pass
* through as-is; percent-off discounts apply to the password manager subtotal
* (`seatsCost + storageCost`). `nextChargeTotal` is computed client-side as
* `seatsCost + storageCost - discountAmount + estimatedTax` because the server
* does not expose a precomputed total.
*/
fun BitwardenSubscriptionResponseJson.toSubscriptionInfo(): SubscriptionInfo {
val seatsCost = cart.passwordManager.seats.cost
val storageCost = cart.passwordManager.additionalStorage?.cost
val discountAmount = cart.discount?.toMoneyAmount(
subtotal = seatsCost + (storageCost ?: BigDecimal.ZERO),
)
val estimatedTax = cart.estimatedTax
val nextChargeTotal = seatsCost +
(storageCost ?: BigDecimal.ZERO) -
(discountAmount ?: BigDecimal.ZERO) +
estimatedTax
return SubscriptionInfo(
status = status.toPremiumSubscriptionStatus(),
cadence = cart.cadence.toPlanCadence(),
seatsCost = seatsCost,
storageCost = storageCost,
discountAmount = discountAmount,
estimatedTax = estimatedTax,
nextChargeTotal = nextChargeTotal,
nextCharge = nextCharge,
canceledDate = canceled,
suspensionDate = suspension,
gracePeriodDays = gracePeriod,
)
}
private fun SubscriptionStatusJson.toPremiumSubscriptionStatus(): PremiumSubscriptionStatus =
when (this) {
SubscriptionStatusJson.ACTIVE,
SubscriptionStatusJson.TRIALING,
-> PremiumSubscriptionStatus.ACTIVE
SubscriptionStatusJson.CANCELED,
SubscriptionStatusJson.INCOMPLETE_EXPIRED,
-> PremiumSubscriptionStatus.CANCELED
SubscriptionStatusJson.INCOMPLETE,
SubscriptionStatusJson.UNPAID,
-> PremiumSubscriptionStatus.OVERDUE_PAYMENT
SubscriptionStatusJson.PAST_DUE -> PremiumSubscriptionStatus.PAST_DUE
SubscriptionStatusJson.PAUSED -> PremiumSubscriptionStatus.PAUSED
}
private fun CadenceTypeJson.toPlanCadence(): PlanCadence = when (this) {
CadenceTypeJson.ANNUALLY -> PlanCadence.ANNUALLY
CadenceTypeJson.MONTHLY -> PlanCadence.MONTHLY
}
private fun BitwardenDiscountJson.toMoneyAmount(subtotal: BigDecimal): BigDecimal =
when (type) {
DiscountTypeJson.AMOUNT_OFF -> value
DiscountTypeJson.PERCENT_OFF ->
subtotal
.multiply(value)
.divide(PERCENT_DIVISOR, MONEY_SCALE, RoundingMode.HALF_EVEN)
}

View File

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

View File

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

View File

@@ -141,47 +141,6 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
*/
fun getPremiumUpgradeBannerDismissedFlow(userId: String): Flow<Boolean?>
/**
* Retrieves the stored value of whether the "Upgraded to Premium" action card has been
* consumed (either dismissed via the close icon or actioned via the Learn more CTA).
*/
fun getUpgradedToPremiumCardConsumed(userId: String): Boolean?
/**
* Stores whether the "Upgraded to Premium" action card has been consumed for the given
* [userId].
*/
fun storeUpgradedToPremiumCardConsumed(
userId: String,
isConsumed: Boolean?,
)
/**
* Emits updates that track [getUpgradedToPremiumCardConsumed] for the given [userId].
*/
fun getUpgradedToPremiumCardConsumedFlow(userId: String): Flow<Boolean?>
/**
* Retrieves the stored value of whether a Free → Premium upgrade has been observed for the
* given [userId] but the resulting "Upgraded to Premium" action card has not yet been
* consumed.
*/
fun getUpgradedToPremiumCardPending(userId: String): Boolean?
/**
* Stores whether a Free → Premium upgrade has been observed for the given [userId] and is
* awaiting consumption of the resulting "Upgraded to Premium" action card.
*/
fun storeUpgradedToPremiumCardPending(
userId: String,
isPending: Boolean?,
)
/**
* Emits updates that track [getUpgradedToPremiumCardPending] for the given [userId].
*/
fun getUpgradedToPremiumCardPendingFlow(userId: String): Flow<Boolean?>
/**
* Retrieves the biometric integrity validity for the given [userId] and
* [systemBioIntegrityState].

View File

@@ -53,10 +53,6 @@ private const val INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED =
"introducingArchiveActionCardDismissed"
private const val PREMIUM_UPGRADE_BANNER_DISMISSED =
"premiumUpgradeBannerDismissed"
private const val UPGRADED_TO_PREMIUM_CARD_CONSUMED =
"upgradedToPremiumCardConsumed"
private const val UPGRADED_TO_PREMIUM_CARD_PENDING =
"upgradedToPremiumCardPending"
/**
* Primary implementation of [SettingsDiskSource].
@@ -101,12 +97,6 @@ class SettingsDiskSourceImpl(
private val mutablePremiumUpgradeBannerDismissedFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableUpgradedToPremiumCardConsumedFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableUpgradedToPremiumCardPendingFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableIsIconLoadingDisabledFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableIsCrashLoggingEnabledFlow = bufferedMutableSharedFlow<Boolean?>()
@@ -262,8 +252,6 @@ class SettingsDiskSourceImpl(
// - should show generator coach mark
// - should show introducing archive action card dismissed
// - Premium upgrade banner dismissed
// - Upgraded to Premium action card consumed
// - Upgraded to Premium action card pending
}
override fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean? =
@@ -306,46 +294,6 @@ class SettingsDiskSourceImpl(
getMutablePremiumUpgradeBannerDismissedFlow(userId = userId)
.onSubscription { emit(getPremiumUpgradeBannerDismissed(userId = userId)) }
override fun getUpgradedToPremiumCardConsumed(userId: String): Boolean? =
getBoolean(
key = UPGRADED_TO_PREMIUM_CARD_CONSUMED.appendIdentifier(identifier = userId),
)
override fun storeUpgradedToPremiumCardConsumed(
userId: String,
isConsumed: Boolean?,
) {
putBoolean(
key = UPGRADED_TO_PREMIUM_CARD_CONSUMED.appendIdentifier(identifier = userId),
value = isConsumed,
)
getMutableUpgradedToPremiumCardConsumedFlow(userId = userId).tryEmit(isConsumed)
}
override fun getUpgradedToPremiumCardConsumedFlow(userId: String): Flow<Boolean?> =
getMutableUpgradedToPremiumCardConsumedFlow(userId = userId)
.onSubscription { emit(getUpgradedToPremiumCardConsumed(userId = userId)) }
override fun getUpgradedToPremiumCardPending(userId: String): Boolean? =
getBoolean(
key = UPGRADED_TO_PREMIUM_CARD_PENDING.appendIdentifier(identifier = userId),
)
override fun storeUpgradedToPremiumCardPending(
userId: String,
isPending: Boolean?,
) {
putBoolean(
key = UPGRADED_TO_PREMIUM_CARD_PENDING.appendIdentifier(identifier = userId),
value = isPending,
)
getMutableUpgradedToPremiumCardPendingFlow(userId = userId).tryEmit(isPending)
}
override fun getUpgradedToPremiumCardPendingFlow(userId: String): Flow<Boolean?> =
getMutableUpgradedToPremiumCardPendingFlow(userId = userId)
.onSubscription { emit(getUpgradedToPremiumCardPending(userId = userId)) }
override fun getAccountBiometricIntegrityValidity(
userId: String,
systemBioIntegrityState: String,
@@ -697,20 +645,6 @@ class SettingsDiskSourceImpl(
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableUpgradedToPremiumCardConsumedFlow(
userId: String,
): MutableSharedFlow<Boolean?> =
mutableUpgradedToPremiumCardConsumedFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableUpgradedToPremiumCardPendingFlow(
userId: String,
): MutableSharedFlow<Boolean?> =
mutableUpgradedToPremiumCardPendingFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableLastSyncFlow(
userId: String,
): MutableSharedFlow<Instant?> =

View File

@@ -19,7 +19,6 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import java.time.Clock
import javax.inject.Singleton
@@ -51,7 +50,7 @@ object PlatformNetworkModule {
@Provides
@Singleton
fun provideBitwardenServiceClientConfig(
fun provideBitwardenServiceClient(
authTokenManager: AuthTokenManager,
baseUrlsProvider: BaseUrlsProvider,
authDiskSource: AuthDiskSource,
@@ -59,28 +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,
json: Json,
): BitwardenServiceClient = bitwardenServiceClient(
config = serviceClientConfig,
json = json,
BitwardenServiceClientConfig(
clock = clock,
appIdProvider = authDiskSource,
clientData = BitwardenServiceClientConfig.ClientData(
userAgent = HEADER_VALUE_USER_AGENT,
clientName = HEADER_VALUE_CLIENT_NAME,
clientVersion = HEADER_VALUE_CLIENT_VERSION,
),
authTokenProvider = authTokenManager,
baseUrlsProvider = baseUrlsProvider,
certificateProvider = certificateManager,
enableHttpBodyLogging = buildInfoManager.isDevBuild,
cookieProvider = networkCookieManager,
),
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
@@ -140,9 +139,7 @@ sealed class SpecialCircumstance : Parcelable {
* indicating the user is returning from a Stripe checkout session.
*/
@Parcelize
data class PremiumCheckout(
val callbackResult: PremiumCheckoutCallbackResult,
) : SpecialCircumstance()
data object PremiumCheckoutResult : SpecialCircumstance()
/**
* The app was launched to select an account to export credentials from.

View File

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

View File

@@ -1,10 +1,7 @@
package com.x8bit.bitwarden.data.platform.manager.sdk
import com.bitwarden.core.ClientManagedTokens
import com.bitwarden.core.ClientSettings
import com.bitwarden.core.DeviceType
import com.bitwarden.data.datasource.disk.ConfigDiskSource
import com.bitwarden.network.model.BitwardenServiceClientConfig
import com.bitwarden.sdk.Repositories
import com.bitwarden.sdk.ServerCommunicationConfigRepository
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
@@ -23,7 +20,6 @@ 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(
@@ -34,30 +30,16 @@ class SdkRepositoryFactoryImpl(
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,

View File

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

View File

@@ -21,9 +21,12 @@ class ServerCommunicationConfigRepositoryImpl(
private val configDiskSource: ConfigDiskSource,
) : ServerCommunicationConfigRepository {
override suspend fun get(domain: String): ServerCommunicationConfig? {
val serverData = configDiskSource.serverConfig?.serverData
val serverCommunicationConfig = serverData?.communication ?: return null
override suspend fun get(hostname: String): ServerCommunicationConfig? {
val serverCommunicationConfig = configDiskSource
.serverConfig
?.serverData
?.communication
?: return null
if (serverCommunicationConfig.bootstrap.type != "ssoCookieVendor") {
return ServerCommunicationConfig(
@@ -31,13 +34,8 @@ class ServerCommunicationConfigRepositoryImpl(
)
}
// We return null here since we do not have the appropriate data to complete the
// transaction. This will trigger a cookie acquisition with the server.
val vaultUrl = serverData.environment?.vaultUrl ?: return null
val cookieName = serverCommunicationConfig.bootstrap.cookieName ?: return null
val cookieDomain = serverCommunicationConfig.bootstrap.cookieDomain ?: return null
val acquiredCookies = cookieDiskSource
.getCookieConfig(hostname = domain)
.getCookieConfig(hostname)
?.cookies
?.toAcquiredCookiesList()
@@ -45,24 +43,24 @@ 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,
vaultUrl = null,
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 +71,7 @@ class ServerCommunicationConfigRepositoryImpl(
BootstrapConfig.Direct -> {
// Clear any existing cookie configuration now that the communication config
// has been updated.
cookieDiskSource.storeCookieConfig(hostname = domain, config = null)
cookieDiskSource.storeCookieConfig(hostname = hostname, config = null)
}
}
}

View File

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

View File

@@ -59,10 +59,4 @@ interface DebugMenuRepository {
* Resets the Premium upgrade banner dismiss status for the current user.
*/
fun resetPremiumUpgradeBannerDismiss()
/**
* Forces the "Upgraded to Premium" action card to be displayed for the current user by
* marking the card pending and clearing any prior consumed state.
*/
fun showUpgradedToPremiumCard()
}

View File

@@ -82,16 +82,4 @@ class DebugMenuRepositoryImpl(
isDismissed = null,
)
}
override fun showUpgradedToPremiumCard() {
val currentUserId = authDiskSource.userState?.activeUserId ?: return
settingsDiskSource.storeUpgradedToPremiumCardConsumed(
userId = currentUserId,
isConsumed = false,
)
settingsDiskSource.storeUpgradedToPremiumCardPending(
userId = currentUserId,
isPending = true,
)
}
}

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ package com.x8bit.bitwarden.data.platform.util
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withTimeoutOrNull
/**
@@ -21,17 +20,3 @@ suspend fun <T> Flow<T>.firstWithTimeoutOrNull(
timeMillis: Long,
predicate: suspend (T) -> Boolean,
): T? = withTimeoutOrNull(timeMillis = timeMillis) { first(predicate) }
/**
* Emits successive (previous, current) pairs from the upstream flow, seeding the first emission's
* `previous` with [initial] (defaulting to `null`).
*/
fun <T> Flow<T>.scanPairs(
initial: T? = null,
): Flow<Pair<T?, T>> = flow {
var previous: T? = initial
collect { current ->
emit(previous to current)
previous = current
}
}

View File

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

View File

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

View File

@@ -82,6 +82,11 @@ interface VaultDiskSource {
*/
fun getCollectionsFlow(userId: String): Flow<List<SyncResponseJson.Collection>>
/**
* Deletes a collection from the data source for the given [userId] and [collectionId].
*/
suspend fun deleteCollection(userId: String, collectionId: String)
/**
* Retrieves all domains from the data source for a given [userId].
*/

View File

@@ -200,6 +200,10 @@ class VaultDiskSourceImpl(
)
}
override suspend fun deleteCollection(userId: String, collectionId: String) {
collectionsDao.deleteCollection(userId = userId, collectionId = collectionId)
}
override fun getCollectionsFlow(
userId: String,
): Flow<List<SyncResponseJson.Collection>> =

View File

@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.vault.datasource.network.di
import com.bitwarden.network.BitwardenServiceClient
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.DownloadService
import com.bitwarden.network.service.CollectionService
import com.bitwarden.network.service.FolderService
import com.bitwarden.network.service.SendsService
import com.bitwarden.network.service.SyncService
@@ -25,6 +26,12 @@ object VaultNetworkModule {
bitwardenServiceClient: BitwardenServiceClient,
): CiphersService = bitwardenServiceClient.ciphersService
@Provides
@Singleton
fun providesCollectionService(
bitwardenServiceClient: BitwardenServiceClient,
): CollectionService = bitwardenServiceClient.collectionService
@Provides
@Singleton
fun providesFolderService(

View File

@@ -239,6 +239,18 @@ interface VaultSdkSource {
collectionList: List<Collection>,
): Result<List<CollectionView>>
/**
* Encrypts a [CollectionView] for the user with the given [userId], returning a [Collection]
* wrapped in a [Result].
*
* This should only be called after a successful call to [initializeCrypto] for the associated
* user.
*/
suspend fun encryptCollection(
userId: String,
collectionView: CollectionView,
): Result<Collection>
/**
* Encrypts a [SendView] for the user with the given [userId], returning a [Send] wrapped
* in a [Result].

View File

@@ -335,6 +335,17 @@ class VaultSdkSourceImpl(
.decryptList(collections = collectionList)
}
override suspend fun encryptCollection(
userId: String,
collectionView: CollectionView,
): Result<Collection> =
runCatchingWithLogs {
getClient(userId = userId)
.vault()
.collections()
.encrypt(collectionView = collectionView)
}
override suspend fun decryptSend(
userId: String,
send: Send,

View File

@@ -0,0 +1,38 @@
package com.x8bit.bitwarden.data.vault.manager
import com.bitwarden.collections.CollectionView
import com.x8bit.bitwarden.data.vault.repository.model.CreateCollectionResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCollectionResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCollectionResult
/**
* Manages the creating, updating, and deleting collections.
*/
interface CollectionManager {
/**
* Attempt to create a collection in the given [organizationId].
* The [organizationUserId] is used to grant the creating user manage access.
*/
suspend fun createCollection(
organizationId: String,
organizationUserId: String?,
collectionView: CollectionView,
): CreateCollectionResult
/**
* Attempt to delete a collection.
*/
suspend fun deleteCollection(
organizationId: String,
collectionId: String,
): DeleteCollectionResult
/**
* Attempt to update a collection.
*/
suspend fun updateCollection(
organizationId: String,
collectionId: String,
collectionView: CollectionView,
): UpdateCollectionResult
}

View File

@@ -0,0 +1,163 @@
package com.x8bit.bitwarden.data.vault.manager
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.network.model.CollectionAccessSelectionJson
import com.bitwarden.network.model.CollectionJsonRequest
import com.bitwarden.network.model.UpdateCollectionResponseJson
import com.bitwarden.network.service.CollectionService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.model.CreateCollectionResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCollectionResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCollectionResult
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCollection
/**
* The default implementation of the [CollectionManager].
*/
class CollectionManagerImpl(
private val authDiskSource: AuthDiskSource,
private val collectionService: CollectionService,
private val vaultDiskSource: VaultDiskSource,
private val vaultSdkSource: VaultSdkSource,
) : CollectionManager {
private val activeUserId: String? get() = authDiskSource.userState?.activeUserId
override suspend fun createCollection(
organizationId: String,
organizationUserId: String?,
collectionView: CollectionView,
): CreateCollectionResult {
val userId = activeUserId
?: return CreateCollectionResult.Error(error = NoActiveUserException())
// Grant the creating user manage access, matching web client behavior.
val users = organizationUserId?.let {
listOf(
CollectionAccessSelectionJson(
id = it,
readOnly = false,
hidePasswords = false,
manage = true,
),
)
}
return vaultSdkSource
.encryptCollection(userId = userId, collectionView = collectionView)
.flatMap {
collectionService.createCollection(
organizationId = organizationId,
body = CollectionJsonRequest(
name = it.name,
users = users,
),
)
}
.onSuccess {
vaultDiskSource.saveCollection(userId = userId, collection = it)
}
.flatMap {
vaultSdkSource.decryptCollection(
userId = userId,
collection = it.toEncryptedSdkCollection(),
)
}
.fold(
onSuccess = { CreateCollectionResult.Success(collectionView = it) },
onFailure = { CreateCollectionResult.Error(error = it) },
)
}
override suspend fun deleteCollection(
organizationId: String,
collectionId: String,
): DeleteCollectionResult {
val userId = activeUserId
?: return DeleteCollectionResult.Error(error = NoActiveUserException())
return collectionService
.deleteCollection(
organizationId = organizationId,
collectionId = collectionId,
)
.onSuccess {
vaultDiskSource.deleteCollection(
userId = userId,
collectionId = collectionId,
)
}
.fold(
onSuccess = { DeleteCollectionResult.Success },
onFailure = { DeleteCollectionResult.Error(error = it) },
)
}
@Suppress("LongMethod")
override suspend fun updateCollection(
organizationId: String,
collectionId: String,
collectionView: CollectionView,
): UpdateCollectionResult {
val userId = activeUserId
?: return UpdateCollectionResult.Error(error = NoActiveUserException())
return collectionService
.getCollectionDetails(
organizationId = organizationId,
collectionId = collectionId,
)
.flatMap { details ->
vaultSdkSource
.encryptCollection(
userId = userId,
collectionView = collectionView,
)
.flatMap { collection ->
collectionService.updateCollection(
organizationId = organizationId,
collectionId = collectionId,
body = CollectionJsonRequest(
name = collection.name,
externalId = details.externalId,
groups = details.groups,
users = details.users,
),
)
}
}
.fold(
onSuccess = { response ->
when (response) {
is UpdateCollectionResponseJson.Success -> {
vaultDiskSource.saveCollection(
userId = userId,
collection = response.collection,
)
vaultSdkSource
.decryptCollection(
userId = userId,
collection = response.collection
.toEncryptedSdkCollection(),
)
.fold(
onSuccess = {
UpdateCollectionResult.Success(it)
},
onFailure = {
UpdateCollectionResult.Error(error = it)
},
)
}
is UpdateCollectionResponseJson.Invalid -> {
UpdateCollectionResult.Error(
errorMessage = response.message,
error = null,
)
}
}
},
onFailure = { UpdateCollectionResult.Error(error = it) },
)
}
}

View File

@@ -28,7 +28,6 @@ import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokens
import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
@@ -41,6 +40,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResul
import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.util.createWrappedAccountCryptographicState
import com.x8bit.bitwarden.data.vault.repository.util.logTag
import com.x8bit.bitwarden.data.vault.repository.util.statusFor
import com.x8bit.bitwarden.data.vault.repository.util.toVaultUnlockResult
@@ -98,7 +98,6 @@ class VaultLockManagerImpl(
context: Context,
) : VaultLockManager {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
private val ioScope = CoroutineScope(dispatcherManager.io)
/**
* This [Map] tracks all active timeout [Job]s that are running and their associated data using
@@ -479,7 +478,7 @@ class VaultLockManagerImpl(
.map { userId -> vaultTimeoutChangesForUserFlow(userId = userId) }
.merge()
}
.launchIn(ioScope)
.launchIn(unconfinedScope)
}
private fun observeUserLogoutResults() {
@@ -681,10 +680,16 @@ class VaultLockManagerImpl(
?: return VaultUnlockResult.InvalidStateError(
error = MissingPropertyException("Private key"),
)
val signingKey = accountKeys?.signatureKeyPair?.wrappedSigningKey
val securityState = accountKeys?.securityState?.securityState
val signedPublicKey = accountKeys?.publicKeyEncryptionKeyPair?.signedPublicKey
val organizationKeys = authDiskSource.getOrganizationKeys(userId = userId)
return unlockVault(
accountCryptographicState = accountKeys.toAccountCryptographicState(
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
signedPublicKey = signedPublicKey,
),
userId = userId,
email = account.profile.email,

View File

@@ -7,11 +7,10 @@ import com.bitwarden.cxf.parser.CredentialExchangePayloadParser
import com.bitwarden.data.manager.appstate.AppStateManager
import com.bitwarden.data.manager.file.FileManager
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.CollectionService
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
@@ -30,6 +29,8 @@ 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
import com.x8bit.bitwarden.data.vault.manager.CipherManagerImpl
import com.x8bit.bitwarden.data.vault.manager.CollectionManager
import com.x8bit.bitwarden.data.vault.manager.CollectionManagerImpl
import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManager
import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManagerImpl
import com.x8bit.bitwarden.data.vault.manager.FolderManager
@@ -62,10 +63,6 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object VaultManagerModule {
@Provides
@Singleton
fun provideCardScanManager(): CardScanManager = CardScanManagerImpl()
@Provides
@Singleton
fun provideVaultMigrationManager(
@@ -120,6 +117,20 @@ object VaultManagerModule {
pushManager = pushManager,
)
@Provides
@Singleton
fun provideCollectionManager(
collectionService: CollectionService,
vaultDiskSource: VaultDiskSource,
vaultSdkSource: VaultSdkSource,
authDiskSource: AuthDiskSource,
): CollectionManager = CollectionManagerImpl(
authDiskSource = authDiskSource,
collectionService = collectionService,
vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource,
)
@Provides
@Singleton
fun provideFolderManager(

View File

@@ -10,6 +10,7 @@ import com.bitwarden.vault.CipherType
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.FolderView
import com.x8bit.bitwarden.data.vault.manager.CipherManager
import com.x8bit.bitwarden.data.vault.manager.CollectionManager
import com.x8bit.bitwarden.data.vault.manager.FolderManager
import com.x8bit.bitwarden.data.vault.manager.SendManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
@@ -32,6 +33,7 @@ import javax.crypto.Cipher
@Suppress("TooManyFunctions")
interface VaultRepository :
CipherManager,
CollectionManager,
FolderManager,
SendManager,
VaultLockManager,

View File

@@ -19,15 +19,14 @@ 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
import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManager
import com.x8bit.bitwarden.data.vault.manager.CollectionManager
import com.x8bit.bitwarden.data.vault.manager.FolderManager
import com.x8bit.bitwarden.data.vault.manager.PinProtectedUserKeyManager
import com.x8bit.bitwarden.data.vault.manager.SendManager
@@ -42,6 +41,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
@@ -75,6 +75,7 @@ class VaultRepositoryImpl(
private val vaultSdkSource: VaultSdkSource,
private val authDiskSource: AuthDiskSource,
private val cipherManager: CipherManager,
private val collectionManager: CollectionManager,
private val folderManager: FolderManager,
private val sendManager: SendManager,
private val vaultLockManager: VaultLockManager,
@@ -85,6 +86,7 @@ class VaultRepositoryImpl(
dispatcherManager: DispatcherManager,
) : VaultRepository,
CipherManager by cipherManager,
CollectionManager by collectionManager,
FolderManager by folderManager,
SendManager by sendManager,
VaultLockManager by vaultLockManager,
@@ -192,7 +194,7 @@ class VaultRepositoryImpl(
.filter {
it.type is CipherListViewType.Login &&
!it.login?.totp.isNullOrBlank() &&
it.isActive
it.deletedDate == null
}
.toFilteredList(vaultFilterType)
}
@@ -539,10 +541,17 @@ class VaultRepositoryImpl(
?: return VaultUnlockResult.InvalidStateError(
error = MissingPropertyException("Private key"),
)
val organizationKeys = authDiskSource.getOrganizationKeys(userId = userId)
val signingKey = accountKeys?.signatureKeyPair?.wrappedSigningKey
val securityState = accountKeys?.securityState?.securityState
val signedPublicKey = accountKeys?.publicKeyEncryptionKeyPair?.signedPublicKey
val organizationKeys = authDiskSource
.getOrganizationKeys(userId = userId)
return vaultLockManager.unlockVault(
accountCryptographicState = accountKeys.toAccountCryptographicState(
accountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
signedPublicKey = signedPublicKey,
),
userId = userId,
email = account.profile.email,

View File

@@ -6,6 +6,7 @@ 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
import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManager
import com.x8bit.bitwarden.data.vault.manager.CollectionManager
import com.x8bit.bitwarden.data.vault.manager.FolderManager
import com.x8bit.bitwarden.data.vault.manager.PinProtectedUserKeyManager
import com.x8bit.bitwarden.data.vault.manager.SendManager
@@ -34,6 +35,7 @@ object VaultRepositoryModule {
vaultSdkSource: VaultSdkSource,
authDiskSource: AuthDiskSource,
cipherManager: CipherManager,
collectionManager: CollectionManager,
folderManager: FolderManager,
sendManager: SendManager,
vaultLockManager: VaultLockManager,
@@ -47,6 +49,7 @@ object VaultRepositoryModule {
vaultSdkSource = vaultSdkSource,
authDiskSource = authDiskSource,
cipherManager = cipherManager,
collectionManager = collectionManager,
folderManager = folderManager,
sendManager = sendManager,
vaultLockManager = vaultLockManager,

View File

@@ -0,0 +1,24 @@
package com.x8bit.bitwarden.data.vault.repository.model
import com.bitwarden.collections.CollectionView
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
/**
* Models result of creating a collection.
*/
sealed class CreateCollectionResult {
/**
* Collection created successfully.
*/
data class Success(val collectionView: CollectionView) : CreateCollectionResult()
/**
* Generic error while creating a collection. The optional [errorMessage] may be displayed
* directly in the UI when present.
*/
data class Error(
val error: Throwable,
val errorMessage: String? = error.userFriendlyMessage,
) : CreateCollectionResult()
}

View File

@@ -0,0 +1,23 @@
package com.x8bit.bitwarden.data.vault.repository.model
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
/**
* Models result of deleting a collection.
*/
sealed class DeleteCollectionResult {
/**
* Collection deleted successfully.
*/
data object Success : DeleteCollectionResult()
/**
* Generic error while deleting a collection. The optional [errorMessage] may be displayed
* directly in the UI when present.
*/
data class Error(
val error: Throwable,
val errorMessage: String? = error.userFriendlyMessage,
) : DeleteCollectionResult()
}

View File

@@ -0,0 +1,24 @@
package com.x8bit.bitwarden.data.vault.repository.model
import com.bitwarden.collections.CollectionView
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
/**
* Models result of updating a collection.
*/
sealed class UpdateCollectionResult {
/**
* Collection updated successfully.
*/
data class Success(val collectionView: CollectionView) : UpdateCollectionResult()
/**
* Generic error while updating a collection. The optional [errorMessage]
* may be displayed directly in the UI when present.
*/
data class Error(
val error: Throwable? = null,
val errorMessage: String? = error?.userFriendlyMessage,
) : UpdateCollectionResult()
}

View File

@@ -12,8 +12,8 @@ val InitUserCryptoMethod.logTag: String
is InitUserCryptoMethod.DecryptedKey -> "Decrypted Key (Never Lock/Biometrics)"
is InitUserCryptoMethod.DeviceKey -> "Device Key"
is InitUserCryptoMethod.KeyConnector -> "Key Connector"
is InitUserCryptoMethod.KeyConnectorUrl -> "Key Connector URL"
is InitUserCryptoMethod.Pin -> "Pin"
is InitUserCryptoMethod.PinEnvelope -> "Pin Envelope"
is InitUserCryptoMethod.KeyConnectorUrl -> "Key Connector Url"
is InitUserCryptoMethod.MasterPasswordUnlock -> "Master Password Unlock"
}

View File

@@ -14,7 +14,6 @@ import com.bitwarden.network.model.SecureNoteTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.UriMatchTypeJson
import com.bitwarden.vault.Attachment
import com.bitwarden.vault.BankAccount
import com.bitwarden.vault.Card
import com.bitwarden.vault.CardListView
import com.bitwarden.vault.Cipher
@@ -67,9 +66,6 @@ fun Cipher.toEncryptedNetworkCipher(
card = card?.toEncryptedNetworkCard(),
key = key,
sshKey = sshKey?.toEncryptedNetworkSshKey(),
bankAccount = bankAccount?.toEncryptedNetworkBankAccount(),
driversLicense = null,
passport = null,
archivedDate = archivedDate,
encryptedFor = encryptedFor,
)
@@ -100,9 +96,6 @@ fun Cipher.toEncryptedNetworkCipherResponse(
card = card?.toEncryptedNetworkCard(),
attachments = attachments?.toNetworkAttachmentList(),
sshKey = sshKey?.toEncryptedNetworkSshKey(),
bankAccount = bankAccount?.toEncryptedNetworkBankAccount(),
driversLicense = null,
passport = null,
shouldOrganizationUseTotp = organizationUseTotp,
shouldEdit = edit,
revisionDate = revisionDate,
@@ -156,24 +149,6 @@ private fun Card.toEncryptedNetworkCard(): SyncResponseJson.Cipher.Card =
brand = brand,
)
/**
* Converts a Bitwarden SDK [BankAccount] object to a corresponding
* [SyncResponseJson.Cipher.BankAccount] object.
*/
private fun BankAccount.toEncryptedNetworkBankAccount(): SyncResponseJson.Cipher.BankAccount =
SyncResponseJson.Cipher.BankAccount(
bankName = bankName,
nameOnAccount = nameOnAccount,
accountType = accountType,
accountNumber = accountNumber,
routingNumber = routingNumber,
branchNumber = branchNumber,
pin = pin,
swiftCode = swiftCode,
iban = iban,
bankContactPhone = bankContactPhone,
)
private fun SshKey.toEncryptedNetworkSshKey(): SyncResponseJson.Cipher.SshKey =
SyncResponseJson.Cipher.SshKey(
publicKey = publicKey,
@@ -398,7 +373,6 @@ private fun CipherType.toNetworkCipherType(): CipherTypeJson =
CipherType.CARD -> CipherTypeJson.CARD
CipherType.IDENTITY -> CipherTypeJson.IDENTITY
CipherType.SSH_KEY -> CipherTypeJson.SSH_KEY
CipherType.BANK_ACCOUNT -> CipherTypeJson.BANK_ACCOUNT
}
/**
@@ -426,7 +400,6 @@ fun SyncResponseJson.Cipher.toEncryptedSdkCipher(): Cipher =
identity = identity?.toSdkIdentity(),
sshKey = sshKey?.toSdkSshKey(),
card = card?.toSdkCard(),
bankAccount = bankAccount?.toSdkBankAccount(),
secureNote = secureNote?.toSdkSecureNote(),
favorite = isFavorite,
reprompt = reprompt.toSdkRepromptType(),
@@ -516,24 +489,6 @@ fun SyncResponseJson.Cipher.Card.toSdkCard(): Card =
number = number,
)
/**
* Transforms a [SyncResponseJson.Cipher.BankAccount] into the corresponding Bitwarden SDK
* [BankAccount].
*/
fun SyncResponseJson.Cipher.BankAccount.toSdkBankAccount(): BankAccount =
BankAccount(
bankName = bankName,
nameOnAccount = nameOnAccount,
accountType = accountType,
accountNumber = accountNumber,
routingNumber = routingNumber,
branchNumber = branchNumber,
pin = pin,
swiftCode = swiftCode,
iban = iban,
bankContactPhone = bankContactPhone,
)
/**
* Transforms a [SyncResponseJson.Cipher.SecureNote] into
* the corresponding Bitwarden SDK [SecureNote].
@@ -652,10 +607,6 @@ fun CipherTypeJson.toSdkCipherType(): CipherType =
CipherTypeJson.CARD -> CipherType.CARD
CipherTypeJson.IDENTITY -> CipherType.IDENTITY
CipherTypeJson.SSH_KEY -> CipherType.SSH_KEY
CipherTypeJson.BANK_ACCOUNT -> CipherType.BANK_ACCOUNT
CipherTypeJson.DRIVERS_LICENSE,
CipherTypeJson.PASSPORT,
-> throw IllegalArgumentException("SDK mapping not yet available for $this")
}
/**
@@ -766,7 +717,6 @@ fun Cipher.toFailureCipherListView(): CipherListView =
CipherType.IDENTITY -> CipherListViewType.Identity
CipherType.SSH_KEY -> CipherListViewType.SshKey
CipherType.BANK_ACCOUNT -> CipherListViewType.BankAccount
},
favorite = favorite,
reprompt = reprompt,

View File

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

View File

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

View File

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

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