Compare commits

..

6 Commits

574 changed files with 5226 additions and 16985 deletions

View File

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

View File

@@ -1,58 +0,0 @@
---
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

@@ -1,72 +0,0 @@
---
description: Guided Android code review workflow through context gathering, Android-specific review, and output
argument-hint: [PR# | PR URL | "local"]
---
# Android Code Review Workflow
You are guiding the developer through a comprehensive Android code review for the Bitwarden Android project.
**Input**: $ARGUMENTS
## Prerequisites
- **Jira/Confluence access**: The `bitwarden-atlassian-tools@bitwarden-marketplace` MCP plugin is required to fetch linked Jira tickets. If unavailable, skip ticket context.
- **GitHub CLI**: Required for fetching PR metadata. Verify with `gh auth status`.
## Workflow Phases
Work through each phase sequentially. **Confirm with the user before advancing to the next phase.** The user may skip phases that are not applicable.
### Phase 1: Ingest
Parse the input to determine review context:
**Source Detection Rules:**
- **PR number** (`123`, `PR #123`, `https://github.com/.../pull/123`): Extract the numeric ID. Fetch PR metadata via `gh pr view <N> --json title,body,headRefName,baseRefName,author,files`. Fetch existing review threads to avoid duplicate comments via `gh api graphql` with `reviewThreads(first: 100)`.
- **"local"** or no argument: Review current branch changes via `git diff main...HEAD` and `git log main...HEAD --oneline --no-merges`.
- **No input**: Ask the user whether to review a PR (provide number/URL) or local branch changes.
**Additional context:**
- Detect Jira ticket references in PR title/body (patterns like `PM-\d+`, `BWA-\d+`). Fetch via `get_issue` if the MCP plugin is available.
- Summarize what was fetched rather than dumping raw content.
**Present a structured summary:**
1. What is being reviewed (PR title/number, branch, or local changes description)
2. Jira ticket context if found (summary and acceptance criteria)
3. Files changed (count and modules affected)
4. Existing review thread count (PR reviews only — avoids duplicate comments)
**Gate**: User confirms the summary is complete before proceeding.
### Phase 2: Review
Invoke the `reviewing-changes` skill and use it to perform the Android-specific code review. Use the PR context from Phase 1 (change type, files affected, modules, Jira requirements) to inform the skill's change type detection and checklist selection.
The skill will:
1. Detect the change type based on files and PR context from Phase 1
2. Load the appropriate type-specific checklist
3. Execute the multi-pass review strategy
4. Consult reference materials as needed
**Before advancing**: Share a summary of key findings (critical issues if any, overall assessment) and confirm the user is ready to output the review.
### Phase 3: Output
Write the completed review to local files:
- `review-summary.md` — Overall assessment (APPROVE / REQUEST CHANGES) plus critical issues list
- `review-inline-comments.md` — All inline findings with `<details>` tags
Follow the exact output format from `.claude/skills/reviewing-changes/examples/review-outputs.md`.
For PR reviews: offer to post the review to GitHub using `gh pr review <N> --comment -b "$(cat review-summary.md)"` for the summary. For inline comments, use the GitHub API or the `bitwarden-code-review` plugin if installed.
**Before advancing**: Confirm the files were written successfully and ask if the user wants to post to GitHub (PR reviews only).
## Guidelines
- Be explicit about which phase you are in at all times.
- Never proceed to another phase without user confirmation.
- If the user wants to skip a phase, acknowledge and move to the next applicable phase.
- If starting from a partially completed review (e.g., review already written), skip to the appropriate phase.

View File

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

View File

@@ -39,14 +39,9 @@ If builds fail resolving the Bitwarden SDK, verify `GITHUB_TOKEN` in `user.prope
**IMPORTANT**: The app module uses the `standard` flavor. Always use `testStandardDebugUnitTest`, NOT `testDebugUnitTest`.
**IMPORTANT**: Always pipe test output through a filter that captures failures on the first run. Gradle suppresses detailed failure output by default, so use `2>&1 | grep -E "FAILED|BUILD|expected:|actual:|AssertionError|failures" | head -30` to see pass/fail results and assertion details without needing a second run.
```bash
# App module tests (correct flavor!)
./gradlew app:testStandardDebugUnitTest 2>&1 | grep -E "FAILED|BUILD|expected:|actual:|AssertionError|failures" | head -30
# Run specific test classes
./gradlew app:testStandardDebugUnitTest --tests "com.x8bit.bitwarden.SomeTest" 2>&1 | grep -E "FAILED|BUILD|expected:|actual:|AssertionError|failures" | head -30
./gradlew app:testStandardDebugUnitTest
# Run all unit tests across all modules
./gradlew test
@@ -61,17 +56,6 @@ If builds fail resolving the Bitwarden SDK, verify `GITHUB_TOKEN` in `user.prope
./gradlew authenticator:testStandardDebugUnitTest
```
### Reading Test Reports
If you need full failure details beyond what grep captures, check the HTML test report:
```bash
# After a test run, open the report at:
# app/build/reports/tests/testStandardDebugUnitTest/index.html
# Or read individual failure XML:
find app/build/test-results -name "*.xml" -exec grep -l "failure" {} \;
```
### Test Structure
```
@@ -94,16 +78,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 +89,6 @@ git add -u && ./gradlew -Pprecommit=true detekt 2>&1 | grep -E "FAILED|BUILD|Lin
./fastlane check
```
### How `-Pprecommit=true` Works
The root `build.gradle.kts` configures detekt tasks to use `git diff --name-only --cached` when this property is set, limiting analysis to staged files only. This is the same mechanism used by the project's pre-commit hook. Stage your changes with `git add` before running.
---
## Codebase Discovery

View File

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

View File

@@ -148,7 +148,9 @@ fun ExampleScreen(
BitwardenTopAppBar(
title = stringResource(R.string.title),
navigationIcon = rememberVectorPainter(BitwardenDrawable.ic_back),
onNavigationIconClick = { viewModel.trySendAction(ExampleAction.BackClick) },
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(ExampleAction.BackClick) }
},
)
},
) {
@@ -163,6 +165,7 @@ fun ExampleScreen(
- ✅ Use `hiltViewModel()` for dependency injection
- ✅ Use `collectAsStateWithLifecycle()` for state (not `collectAsState()`)
- ✅ Use `EventsEffect(viewModel)` for one-shot events
- ✅ Use `remember(viewModel) { }` for stable callbacks to prevent recomposition
- ✅ Use `Bitwarden*` prefixed components from `:ui` module
**State Hoisting Rules:**

View File

@@ -342,7 +342,9 @@ fun ExampleScreen(
// Dialogs
ExampleDialogs(
dialogState = state.dialogState,
onDismissRequest = { viewModel.trySendAction(ExampleAction.ErrorDialogDismiss) },
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(ExampleAction.ErrorDialogDismiss) }
},
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
@@ -355,14 +357,20 @@ fun ExampleScreen(
title = stringResource(id = BitwardenString.example),
scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
onNavigationIconClick = { viewModel.trySendAction(ExampleAction.BackClick) },
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(ExampleAction.BackClick) }
},
)
},
) {
ExampleScreenContent(
state = state,
onInputChanged = { viewModel.trySendAction(ExampleAction.InputChanged(it)) },
onSubmitClick = { viewModel.trySendAction(ExampleAction.SubmitClick) },
onInputChanged = remember(viewModel) {
{ viewModel.trySendAction(ExampleAction.InputChanged(it)) }
},
onSubmitClick = remember(viewModel) {
{ viewModel.trySendAction(ExampleAction.SubmitClick) }
},
modifier = Modifier
.fillMaxSize(),
)

View File

@@ -1,6 +1,7 @@
---
name: reviewing-changes
description: Android-specific code review checklist and MVVM/Compose pattern validation for Bitwarden Android — use this for any review task, even if the user doesn't explicitly ask for a "checklist". Detects change type automatically and loads the right review strategy (feature additions, bug fixes, UI refinements, refactoring, dependency updates, infrastructure). Triggered by "review PR", "review changes", "review this code", "check this code", "Android review", code review requests on Kotlin/ViewModel/Composable/Repository/Gradle files, or any time someone asks to look at a diff, PR, or code changes in bitwarden/android.
version: 3.0.0
description: Guides Android code reviews with type-specific checklists and MVVM/Compose pattern validation. Use when reviewing Android PRs, pull requests, diffs, or local changes involving Kotlin, ViewModel, Composable, Repository, or Gradle files. Triggered by "review PR", "review changes", "check this code", "Android review", or code review requests mentioning bitwarden/android. Loads specialized checklists for feature additions, bug fixes, UI refinements, refactoring, dependency updates, and infrastructure changes.
---
# Reviewing Changes - Android Additions
@@ -9,10 +10,16 @@ This skill provides Android-specific workflow additions that complement the base
## Instructions
**IMPORTANT**: Work systematically through each step before providing feedback. Each checklist file includes structured thinking guidance for its review passes.
**IMPORTANT**: Use structured thinking throughout your review process. Plan your analysis in `<thinking>` tags before providing final feedback.
### Step 1: Retrieve Additional Details
<thinking>
Determine if more context is available for the changes:
1. Are there JIRA tickets or GitHub Issues mentioned in the PR title or body?
2. Are there other GitHub pull requests mentioned in the PR title or body?
</thinking>
Retrieve any additional information linked to the pull request using available tools (JIRA MCP, GitHub API).
If pull request title and message do not provide enough context, request additional details from the reviewer:
@@ -21,12 +28,16 @@ If pull request title and message do not provide enough context, request additio
- Link to another pull request
- Add more detail to the PR title or body
**Android metadata checks** — flag as ❓ if any of these are missing:
- PR includes `*Screen.kt` or Composable changes but has no screenshots
- PR adds new `ViewModel` or `Repository` but has no test plan or test file changes
### Step 2: Detect Change Type with Android Refinements
<thinking>
Analyze the changeset systematically:
1. What files were modified? (code vs config vs docs)
2. What is the PR/commit title indicating?
3. Is there new functionality or just modifications?
4. What's the risk level of these changes?
</thinking>
Use the base change type detection from the agent, with Android-specific refinements:
**Android-specific patterns:**
@@ -54,13 +65,21 @@ The checklist provides:
### Step 4: Execute Review Following Checklist
<thinking>
Before diving into details:
1. What are the highest-risk areas of this change?
2. Which architectural patterns need verification?
3. What security implications exist?
4. How should I prioritize my findings?
5. What tone is appropriate for this feedback?
</thinking>
Follow the checklist's multi-pass strategy, thinking through each pass systematically.
### Step 5: Consult Android Reference Materials As Needed
Load reference files only when needed for specific questions:
- **Re-reviews** → invoke `reviewing-incremental-changes` agent skill; scope to changed lines only, do not flag new issues in unchanged code
- **Issue prioritization** → `reference/priority-framework.md` (Critical vs Suggested vs Optional)
- **Phrasing feedback** → `reference/review-psychology.md` (questions vs commands, I-statements)
- **Architecture questions** → `reference/architectural-patterns.md` (MVVM, Hilt DI, module org, error handling)
@@ -72,7 +91,6 @@ Load reference files only when needed for specific questions:
## Core Principles
- **Priority order**: Security → Correctness → Breaking Changes → Performance → Maintainability
- **Appropriate depth**: Match review rigor to change complexity and risk
- **Specific references**: Always use `file:line_number` format for precise location
- **Actionable feedback**: Say what to do and why, not just what's wrong

View File

@@ -4,6 +4,15 @@
### First Pass: Understand the Bug
<thinking>
Before evaluating the fix:
1. What was the original bug/broken behavior?
2. What is the expected correct behavior?
3. What was the root cause?
4. How was the bug discovered? (user report, test, production)
5. What's the severity? (crash, data loss, UI glitch, minor annoyance)
</thinking>
**1. Understand root cause:**
- What was the broken behavior?
- What caused it?
@@ -20,6 +29,15 @@
### Second Pass: Verify the Fix
<thinking>
Evaluate the fix systematically:
1. Does this fix address the root cause or just symptoms?
2. Are there edge cases not covered?
3. Could this break other functionality?
4. Is the fix localized or does it ripple through the codebase?
5. How do we prevent this bug from returning?
</thinking>
**4. Code changes:**
- Does the fix make sense?
- Is it the simplest solution?
@@ -83,7 +101,16 @@ Use `reference/priority-framework.md` to classify findings as Critical/Important
## Output Format
See `examples/review-outputs.md` for the required output format and inline comment structure.
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
```markdown
**Overall Assessment:** APPROVE / REQUEST CHANGES
**Critical Issues** (if any):
- [One-line summary of each critical blocking issue with file:line reference]
See inline comments for all issue details.
```
## Example Review

View File

@@ -4,6 +4,15 @@
### First Pass: Identify and Assess
<thinking>
Before diving into details:
1. Which dependencies were updated?
2. What are the version changes? (patch, minor, major)
3. Are any security-sensitive libraries involved? (crypto, auth, networking)
4. Any pre-release versions (alpha, beta, RC)?
5. What's the blast radius if something breaks?
</thinking>
**1. Identify the change:**
- Which library? Old version → New version?
- Major (X.0.0), Minor (0.X.0), or Patch (0.0.X) version change?
@@ -16,6 +25,15 @@
### Second Pass: Deep Analysis
<thinking>
For each dependency update:
1. What changes are in this release?
2. Are there breaking changes?
3. Are there security fixes?
4. Do we use the affected APIs?
5. How does this affect our codebase?
</thinking>
**3. Review release notes** (if available):
- Breaking changes mentioned?
- Security fixes included?
@@ -74,7 +92,16 @@ Use `reference/priority-framework.md` to classify findings as Critical/Important
## Output Format
See `examples/review-outputs.md` for the required output format and inline comment structure.
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
```markdown
**Overall Assessment:** APPROVE / REQUEST CHANGES
**Critical Issues** (if any):
- [One-line summary of each critical blocking issue with file:line reference]
See inline comments for all issue details.
```
## Example Reviews

View File

@@ -4,6 +4,15 @@
### First Pass: High-Level Assessment
<thinking>
Before diving into details:
1. What is this feature supposed to do?
2. How does it fit into the existing architecture?
3. What are the security implications?
4. What's the scope? (files touched, modules affected)
5. What are the highest-risk areas?
</thinking>
**1. Understand the feature:**
- Read PR description - what problem does this solve?
- Identify user-facing changes vs internal changes
@@ -21,6 +30,15 @@
### Second Pass: Architecture Deep-Dive
<thinking>
Verify architectural integrity:
1. Does this follow MVVM + UDF pattern?
2. Is Hilt DI used correctly?
3. Is state management proper (StateFlow, immutability)?
4. Are modules organized correctly?
5. Is error handling robust (Result types)?
</thinking>
**4. MVVM + UDF Pattern Compliance:**
- ViewModels properly structured?
- State management using StateFlow?
@@ -42,6 +60,15 @@
### Third Pass: Details and Quality
<thinking>
Check quality and completeness:
1. Is code quality high? (null safety, documentation, naming)
2. Are tests comprehensive? (unit + integration)
3. Are there edge cases not covered?
4. Is documentation clear?
5. Are there any code smells or anti-patterns?
</thinking>
**8. Testing:**
- Unit tests for ViewModels and repositories?
- Test coverage for edge cases and error scenarios?
@@ -59,13 +86,144 @@
## Architecture Review
Read `reference/architectural-patterns.md` for full patterns and code examples.
### MVVM Pattern Compliance
**Check these four areas:**
- **MVVM/UDF**: ViewModel exposes `StateFlow` (not `MutableStateFlow`), business logic in Repository, UI is stateless
- **Hilt DI**: `@HiltViewModel` + `@Inject constructor`, inject interfaces not implementations, no manual instantiation
- **Module placement**: UI in `:ui`/`:app`, data in `:data`, network in `:network`, no circular dependencies
- **Error handling**: `Result<T>` / `runCatching` throughout — no thrown exceptions from data layer
Read `reference/architectural-patterns.md` for detailed patterns.
**ViewModels must:**
- Use `@HiltViewModel` annotation
- Use `@Inject constructor`
- Expose `StateFlow<T>`, NOT `MutableStateFlow<T>` publicly
- Delegate business logic to Repository/Manager
- Avoid direct Android framework dependencies (except ViewModel, SavedStateHandle)
**Common Violations:**
```kotlin
// ❌ BAD - Exposes mutable state
class FeatureViewModel @Inject constructor() : ViewModel() {
val state: MutableStateFlow<State> = MutableStateFlow(State.Initial)
}
// ✅ GOOD - Exposes immutable state
class FeatureViewModel @Inject constructor() : ViewModel() {
private val _state = MutableStateFlow<State>(State.Initial)
val state: StateFlow<State> = _state.asStateFlow()
}
// ❌ BAD - Business logic in ViewModel
fun onSubmit() {
val encrypted = encryptionManager.encrypt(password) // Should be in Repository
_state.value = State.Success
}
// ✅ GOOD - Business logic in Repository, state updated via internal event
fun onSubmit() {
viewModelScope.launch {
// The result of the async operation is captured
val result = repository.submitData(password)
// A single event is sent with the result, not updating state directly
sendAction(FeatureAction.Internal.SubmissionComplete(result))
}
}
// The ViewModel has a handler that processes the internal event
private fun handleInternalAction(action: FeatureAction.Internal) {
when (action) {
is FeatureAction.Internal.SubmissionComplete -> {
// The event handler evaluates the result and updates state
action.result.fold(
onSuccess = { _state.value = State.Success },
onFailure = { _state.value = State.Error(it) }
)
}
}
}
```
**UI Layer must:**
- Only observe state, never modify
- Pass user actions as events to ViewModel
- Contain no business logic
- Use existing UI components from `:ui` module where possible
### Hilt Dependency Injection
Reference: `docs/ARCHITECTURE.md#dependency-injection`
**Required Patterns:**
- ViewModels: `@HiltViewModel` + `@Inject constructor`
- Repositories: `@Inject constructor` on implementation
- Inject interfaces, not concrete implementations
- Modules must provide proper scoping (`@Singleton`, `@ViewModelScoped`)
**Common Violations:**
```kotlin
// ❌ BAD - Manual instantiation
class FeatureViewModel : ViewModel() {
private val repository = FeatureRepositoryImpl()
}
// ✅ GOOD - Injected interface
@HiltViewModel
class FeatureViewModel @Inject constructor(
private val repository: FeatureRepository // Interface, not implementation
) : ViewModel()
// ❌ BAD - Injecting implementation
class FeatureViewModel @Inject constructor(
private val repository: FeatureRepositoryImpl // Should inject interface
)
// ✅ GOOD - Interface injection
class FeatureViewModel @Inject constructor(
private val repository: FeatureRepository // Interface
)
```
### Module Organization
Reference: `docs/ARCHITECTURE.md#module-structure`
**Correct Placement:**
- `:core` - Shared utilities (cryptography, analytics, logging)
- `:data` - Repositories, database, domain models
- `:network` - API clients, network utilities
- `:ui` - Reusable Compose components, theme
- `:app` - Feature screens, ViewModels, navigation
- `:authenticator` - Authenticator app (separate from password manager)
**Check:**
- UI code in `:ui` or `:app` modules
- Data models in `:data`
- Network clients in `:network`
- No circular dependencies between modules
### Error Handling
Reference: `docs/ARCHITECTURE.md#error-handling`
**Required Pattern - Use Result types:**
```kotlin
// ✅ GOOD - Result type
suspend fun fetchData(): Result<Data> = runCatching {
apiService.getData()
}
// ViewModel handles Result
repository.fetchData().fold(
onSuccess = { data -> _state.value = State.Success(data) },
onFailure = { error -> _state.value = State.Error(error) }
)
// ❌ BAD - Exception-based in business logic
suspend fun fetchData(): Data {
try {
return apiService.getData()
} catch (e: Exception) {
throw FeatureException(e) // Don't throw in business logic
}
}
```
## Security Review
@@ -208,4 +366,15 @@ Use `reference/review-psychology.md` for phrasing guidance.
## Output Format
See `examples/review-outputs.md` for the required output format and inline comment structure.
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
See `examples/review-outputs.md` for comprehensive feature review example.
```markdown
**Overall Assessment:** APPROVE / REQUEST CHANGES
**Critical Issues** (if any):
- [One-line summary of each critical blocking issue with file:line reference]
See inline comments for all issue details.
```

View File

@@ -4,6 +4,15 @@
### First Pass: Understand the Change
<thinking>
Assess infrastructure change:
1. What problem does this solve?
2. Does this affect production builds, CI/CD, or dev workflow?
3. What's the risk if this breaks?
4. Can this be tested before merge?
5. What's the rollback plan?
</thinking>
**1. Identify the goal:**
- What problem does this solve?
- Is this optimization, fix, or new capability?
@@ -21,6 +30,15 @@
### Second Pass: Verify Implementation
<thinking>
Verify configuration and impact:
1. Is the configuration syntax valid?
2. Are secrets/credentials handled securely?
3. What's the impact on build times and CI performance?
4. How will this affect the team's workflow?
5. Is there adequate testing/validation?
</thinking>
**4. Configuration correctness:**
- Syntax valid?
- References correct?
@@ -171,7 +189,16 @@ Use `reference/priority-framework.md` to classify findings as Critical/Important
## Output Format
See `examples/review-outputs.md` for the required output format and inline comment structure.
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
```markdown
**Overall Assessment:** APPROVE / REQUEST CHANGES
**Critical Issues** (if any):
- [One-line summary of each critical blocking issue with file:line reference]
See inline comments for all issue details.
```
## Example Review

View File

@@ -4,6 +4,15 @@
### First Pass: Understand the Refactoring
<thinking>
Analyze the refactoring scope:
1. What pattern is being improved?
2. Why is this refactoring needed?
3. Does this change behavior or just structure?
4. What's the scope? (files affected, migration completeness)
5. What are the risks if something breaks?
</thinking>
**1. Understand the goal:**
- What pattern is being improved?
- Why is this refactoring needed?
@@ -21,6 +30,15 @@
### Second Pass: Verify Consistency
<thinking>
Verify refactoring quality:
1. Is the new pattern applied consistently throughout?
2. Are there missed instances of the old pattern?
3. Do tests still pass with same behavior?
4. Is the migration complete or partial?
5. Does this introduce any new issues?
</thinking>
**4. Pattern consistency:**
- Is the new pattern applied consistently throughout?
- Are there missed instances of the old pattern?
@@ -151,7 +169,16 @@ Use `reference/priority-framework.md` to classify findings as Critical/Important
## Output Format
See `examples/review-outputs.md` for the required output format and inline comment structure.
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
```markdown
**Overall Assessment:** APPROVE / REQUEST CHANGES
**Critical Issues** (if any):
- [One-line summary of each critical blocking issue with file:line reference]
See inline comments for all issue details.
```
## Example Reviews

View File

@@ -4,6 +4,15 @@
### First Pass: Visual Changes
<thinking>
Analyze the UI changes:
1. What visual/UX problem is being solved?
2. Are there designs or screenshots to reference?
3. Is this affecting existing screens or new ones?
4. What's the scope of visual changes?
5. Are design tokens (colors, spacing, typography) being used correctly?
</thinking>
**1. Understand the changes:**
- What visual/UX problem is being solved?
- Are there designs or screenshots to reference?
@@ -16,6 +25,15 @@
### Second Pass: Implementation Review
<thinking>
Check implementation quality:
1. Are Compose best practices followed?
2. Is state hoisting applied correctly?
3. Are existing components reused where possible?
4. Is accessibility properly handled?
5. Does this follow design system patterns?
</thinking>
**3. Compose best practices:**
- Composables properly structured?
- State hoisted correctly?
@@ -169,7 +187,16 @@ Use `reference/priority-framework.md` to classify findings as Critical/Important
## Output Format
See `examples/review-outputs.md` for the required output format and inline comment structure.
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
```markdown
**Overall Assessment:** APPROVE / REQUEST CHANGES
**Critical Issues** (if any):
- [One-line summary of each critical blocking issue with file:line reference]
See inline comments for all issue details.
```
## Example Review

View File

@@ -50,34 +50,21 @@ Reference: [docs link if applicable]
- ⚠️ **IMPORTANT** - Should fix (missing tests, quality issues)
- ♻️ **DEBT** - Technical debt (duplication, convention violations, future rework needed)
- 🎨 **SUGGESTED** - Nice to have (refactoring, improvements)
- **QUESTION** - Seeking clarification (requirements, design decisions)
- 💭 **QUESTION** - Seeking clarification (requirements, design decisions)
### Summary Comment Format
Uses the agent's `posting-review-summary` skill format. Surface ❌ CRITICAL issues at the top level for immediate visibility, wrap the full findings list in `<details>` for scannability.
**Required format for ALL PRs:**
```
**Overall Assessment:** APPROVE / REQUEST CHANGES
[1-2 neutral sentences describing what was reviewed]
**Critical Issues** (if any):
- ❌ [One-line summary with file:line]
- [issue with file:line]
<details>
<summary>All findings</summary>
- ❌ **CRITICAL**: [description] (`file:line`)
- ⚠️ **IMPORTANT**: [description] (`file:line`)
- ♻️ **DEBT**: [description] (`file:line`)
- 🎨 **SUGGESTED**: [description] (`file:line`)
- ❓ **QUESTION**: [description] (`file:line`)
</details>
See inline comments for details.
```
For clean PRs with no findings, omit both sections entirely — verdict + 1-2 sentences is sufficient.
**GitHub pitfall**: Never use `#` followed by a number in comment text (e.g., `#42`, `#PR123`). GitHub autolinks these to issues/PRs. Use `Finding 1:` or `item 42` instead.
All PRs use the same minimal format - no exceptions for size or complexity. Summary must be 5-10 lines maximum.
---
@@ -281,7 +268,7 @@ Would add security layer against brute force. Consider discussing threat model w
**Inline Comment 5** (on `app/vault/unlock/UnlockScreen.kt:134`):
```markdown
**QUESTION**: Can we use BitwardenTextField?
💭 **QUESTION**: Can we use BitwardenTextField?
<details>
<summary>Details</summary>

View File

@@ -9,7 +9,7 @@ Use this framework to classify findings during code review. Clear prioritization
- [⚠️ IMPORTANT (Should Fix)](#important-should-fix)
- [♻️ DEBT (Technical Debt)](#debt-technical-debt)
- [🎨 SUGGESTED (Nice to Have)](#suggested-nice-to-have)
- [ QUESTION (Seeking Clarification)](#question-seeking-clarification)
- [💭 QUESTION (Seeking Clarification)](#question-seeking-clarification)
- [Optional (Acknowledge But Don't Require)](#optional-acknowledge-but-dont-require)
**Guidelines:**
@@ -170,12 +170,13 @@ Will require rework when experimentation framework launches.
## 🎨 **SUGGESTED** (Nice to Have)
Improvements with measurable value only. A finding qualifies as SUGGESTED if it provides: security gain, cyclomatic complexity reduction, bug class prevention, or elimination of an O(n²) pattern. Subjective style preferences, vague simplifications, and naming nitpicks do not qualify — leave those out entirely or raise in conversation.
These are improvement opportunities but not required. Consider the effort vs. benefit before requesting changes.
### Code Quality
- Extractable duplicated logic that reduces measurable complexity or improves testability
- Patterns that would prevent a recurring bug class in this module
- Architecture improvements that eliminate tight coupling with measurable impact
- Minor style inconsistencies (if not caught by linter)
- Opportunities for DRY improvements
- Better variable naming for clarity
- Simplification opportunities
**Example**:
```
@@ -207,7 +208,7 @@ Could be extracted to separate validator class for reusability and testing.
---
## **QUESTION** (Seeking Clarification)
## 💭 **QUESTION** (Seeking Clarification)
Questions about requirements, unclear intent, or potential conflicts that require human knowledge to answer. Open inquiries that cannot be resolved through code inspection alone.

View File

@@ -5,6 +5,7 @@ Effective code review feedback is clear, actionable, and constructive. This guid
## Table of Contents
**Guidelines:**
- [Core Directives](#core-directives)
- [Phrasing Templates](#phrasing-templates)
- [Critical Issues (Prescriptive)](#critical-issues-prescriptive)
- [Suggested Improvements (Exploratory)](#suggested-improvements-exploratory)
@@ -15,6 +16,17 @@ Effective code review feedback is clear, actionable, and constructive. This guid
---
## Core Directives
- **Keep positive feedback minimal**: For clean PRs with no issues, use 2-3 line approval only. When acknowledging good practices in PRs with issues, use single bullet list with no elaboration. Never create elaborate sections praising correct implementations.
- Ask questions for design decisions, be prescriptive for clear violations
- Focus on code, not people ("This code..." not "You...")
- Use I-statements for subjective feedback ("Hard for me to understand...")
- Explain rationale with every recommendation
- Avoid: "just", "simply", "obviously", "easy"
---
## Phrasing Templates
### Critical Issues (Prescriptive)

View File

@@ -245,8 +245,6 @@ fun `test exception`() {
Common testing mistakes in Bitwarden. **For complete details and examples:** See `references/critical-gotchas.md`
> **⛔ STOP — `@Suppress("MaxLineLength")`**: Do NOT add this annotation unless the `fun` declaration line **actually exceeds 100 characters**. Count the characters first. Do not copy it from nearby tests. Detekt will tell you if it's needed — when in doubt, leave it off.
**Core Patterns:**
- **assertCoroutineThrows + runTest** - Never wrap in `runTest`; call directly
- **Static mock cleanup** - Always `unmockkStatic()` in `@After`
@@ -284,10 +282,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

@@ -1,150 +0,0 @@
#!/usr/bin/env python3
# Requires Python 3.9+
"""
Comment GitHub issues linked to Pull Requests mentioned in a given release.
Usage:
python gh_release_update_issues.py <release_url> [--dry-run]
Arguments:
release-url: The URL of the release to comment on
--dry-run: Run without actually updating issues
Examples:
python gh_release_update_issues.py https://github.com/owner/repo/releases/tag/v1.0.0
python gh_release_update_issues.py https://github.com/owner/repo/releases/tag/v1.0.0 --dry-run
"""
import re
import subprocess
import json
import argparse
from collections import defaultdict
from typing import List, Tuple, Dict
def parse_release_url(release_url: str) -> Tuple[str, str, str]:
"""Extract owner, repo name, and tag from a GitHub release URL.
Returns:
Tuple of (owner, repo_name, release_tag)
"""
match = re.search(r'github\.com/([\w-]+)/([\w.-]+)/releases/tag/(.+)$', release_url)
if not match:
raise ValueError(f"Cannot parse release URL: {release_url}")
return match.group(1), match.group(2), match.group(3)
def extract_pr_numbers(release_notes: str) -> List[int]:
return [int(n) for n in re.findall(r'/pull/(\d+)', release_notes)]
def build_issue_comment(repo: str, release_name: str, release_link: str, pr_numbers: List[int]) -> str:
if len(pr_numbers) == 0:
return ""
pr_links = [f"* https://github.com/{repo}/pull/{pr_number}" for pr_number in pr_numbers]
return f":shipit: Pull Request(s) linked to this issue released in [{release_name}]({release_link}):\n\n"+ "\n".join(pr_links)
def gh_fetch_release(repo: str, release_tag: str) -> Tuple[str, str]:
result = subprocess.run(
['gh', 'release', 'view', release_tag, '--repo', repo, '--json', 'name,body'],
capture_output=True, text=True, check=True
)
data = json.loads(result.stdout)
return data['name'], data['body']
def gh_comment_issue(repo: str, issue_number: int, comment: str) -> None:
"""Use GitHub CLI to comment on an issue.
"""
subprocess.run([
'gh', 'issue', 'comment', str(issue_number), '--body', comment, '--repo', repo
], check=True)
def gh_fetch_linked_issues_batched(owner: str, repo_name: str, pr_numbers: List[int]) -> Dict[int, List[int]]:
"""Batch-fetch linked issues for all PRs in a single GraphQL call.
Returns:
Dict mapping each PR number to its list of linked issue numbers.
"""
if not pr_numbers:
return {}
tmpl = 'pr_%d: pullRequest(number: %d) { closingIssuesReferences(first: 100) { nodes { number } } }'
pr_fragments = "\n".join(tmpl % (pr, pr) for pr in pr_numbers)
query = """
query ($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
%s
}
}
""" % pr_fragments
try:
result = subprocess.run(
[
'gh', 'api', 'graphql',
'-F', f'owner={owner}',
'-F', f'repo={repo_name}',
'-f', f'query={query}',
],
capture_output=True, text=True, check=True,
)
data = json.loads(result.stdout)
repo_data = data['data']['repository']
pr_issues_map: Dict[int, List[int]] = {}
for pr_number in pr_numbers:
nodes = repo_data.get(f'pr_{pr_number}', {}).get('closingIssuesReferences', {}).get('nodes', [])
pr_issues = [node['number'] for node in nodes]
pr_issues_map[pr_number] = pr_issues
return pr_issues_map
except subprocess.CalledProcessError as e:
print(f"::error::Error batch-fetching linked issues: {e.stderr}")
raise
def map_issues_to_prs(pr_issues_map: Dict[int, List[int]]) -> Dict[int, List[int]]:
"""Invert a PR->issues map into an issue->PRs map."""
issue_pr_map: Dict[int, List[int]] = defaultdict(list)
for pr_number, issue_numbers in pr_issues_map.items():
for issue_number in issue_numbers:
issue_pr_map[issue_number].append(pr_number)
return dict(issue_pr_map)
def comment_issues(repo: str, issue_pr_map: Dict[int, List[int]], release_name: str, release_url: str, dry_run: bool) -> None:
for issue_number, linked_prs in issue_pr_map.items():
comment = build_issue_comment(repo, release_name, release_url, linked_prs)
print(f"{'Dry run - ' if dry_run else ''}Commenting on issue {issue_number}:\n{comment}\n")
if not dry_run and comment:
gh_comment_issue(repo, issue_number, comment)
def parse_args():
parser = argparse.ArgumentParser(
description='Comment GitHub issues linked to Pull Requests mentioned in a given release.'
)
parser.add_argument(
'release_url',
help='Release URL (e.g. https://github.com/owner/repo/releases/tag/v1.0.0)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Run without actually commenting issues'
)
return parser.parse_args()
if __name__ == '__main__':
args = parse_args()
owner, repo_name, release_tag = parse_release_url(args.release_url)
repo = f"{owner}/{repo_name}"
print(f"📋 Release URL: {args.release_url}")
release_name, release_notes = gh_fetch_release(repo, release_tag)
print(f"📋 Release Name: {release_name}")
pr_numbers = extract_pr_numbers(release_notes)
print(f"📋 PR Numbers parsed from release notes: {pr_numbers}")
pr_issues_map = gh_fetch_linked_issues_batched(owner, repo_name, pr_numbers)
print(f"📋 PRs with linked issues: {[pr for pr, issues in pr_issues_map.items() if issues]}\n")
issue_pr_map = map_issues_to_prs(pr_issues_map)
comment_issues(repo, issue_pr_map, release_name, args.release_url, args.dry_run)

View File

@@ -167,7 +167,7 @@ jobs:
echo '```' >> "$GITHUB_STEP_SUMMARY"
- name: Upload version info artifact
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: version-info
path: version_info.json

View File

@@ -49,10 +49,35 @@ jobs:
version_number: ${{ inputs.version-code }}
patch_version: ${{ inputs.patch_version && '999' || '' }}
build:
name: Build Authenticator
runs-on: ubuntu-24.04
steps:
- name: Log inputs to job summary
uses: bitwarden/android/.github/actions/log-inputs@main
with:
inputs: "${{ toJson(inputs) }}"
- name: Check out repo
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- name: Setup Android Build
uses: ./.github/actions/setup-android-build
- name: Check Authenticator
run: bundle exec fastlane check
- name: Build Authenticator
run: bundle exec fastlane buildAuthenticatorDebug
publish_playstore:
name: Publish Authenticator Play Store artifacts
needs:
- version
- build
runs-on: ubuntu-24.04
permissions:
id-token: write
@@ -176,7 +201,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.aab
if: ${{ matrix.variant == 'aab' }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.bitwarden.authenticator.aab
path: authenticator/build/outputs/bundle/release/com.bitwarden.authenticator.aab
@@ -184,7 +209,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.apk
if: ${{ matrix.variant == 'apk' }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.bitwarden.authenticator.apk
path: authenticator/build/outputs/apk/release/com.bitwarden.authenticator.apk
@@ -204,7 +229,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
if: ${{ matrix.variant == 'apk' }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: authenticator-android-apk-sha256.txt
path: ./authenticator-android-apk-sha256.txt
@@ -212,7 +237,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
if: ${{ matrix.variant == 'aab' }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: authenticator-android-aab-sha256.txt
path: ./authenticator-android-aab-sha256.txt

View File

@@ -65,7 +65,7 @@ jobs:
run: ./gradlew :testharness:assembleDebug
- name: Upload Test Harness APK
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.bitwarden.testharness.dev-debug.apk
path: testharness/build/outputs/apk/debug/com.bitwarden.testharness.dev.apk
@@ -77,7 +77,7 @@ jobs:
> ./com.bitwarden.testharness.dev.apk-sha256.txt
- name: Upload Test Harness SHA file
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.bitwarden.testharness.dev.apk-sha256.txt
path: ./com.bitwarden.testharness.dev.apk-sha256.txt

View File

@@ -51,10 +51,42 @@ jobs:
version_number: ${{ inputs.version-code }}
patch_version: ${{ inputs.patch_version && '999' || '' }}
build:
name: Build
runs-on: ubuntu-24.04
steps:
- name: Log inputs to job summary
uses: bitwarden/android/.github/actions/log-inputs@main
with:
inputs: "${{ toJson(inputs) }}"
- name: Check out repo
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- name: Setup Android Build
uses: ./.github/actions/setup-android-build
- name: Check
run: bundle exec fastlane check
- name: Build
run: bundle exec fastlane assembleDebugApks
- name: Upload test reports on failure
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: failure()
with:
name: test-reports
path: app/build/reports/tests/
publish_playstore:
name: Publish Play Store artifacts
needs:
- version
- build
runs-on: ubuntu-24.04
permissions:
id-token: write
@@ -192,7 +224,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.aab
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.x8bit.bitwarden.aab
path: app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden.aab
@@ -200,7 +232,7 @@ jobs:
- name: Upload to GitHub Artifacts - beta.aab
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.x8bit.bitwarden.beta.aab
path: app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden.beta.aab
@@ -208,7 +240,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.apk
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.x8bit.bitwarden.apk
path: app/build/outputs/apk/standard/release/com.x8bit.bitwarden.apk
@@ -216,7 +248,7 @@ jobs:
- name: Upload to GitHub Artifacts - beta.apk
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.x8bit.bitwarden.beta.apk
path: app/build/outputs/apk/standard/beta/com.x8bit.bitwarden.beta.apk
@@ -225,7 +257,7 @@ jobs:
# When building variants other than 'prod'
- name: Upload to GitHub Artifacts - dev.apk
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk
path: app/build/outputs/apk/standard/debug/com.x8bit.bitwarden.dev.apk
@@ -263,7 +295,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.x8bit.bitwarden.apk-sha256.txt
path: ./com.x8bit.bitwarden.apk-sha256.txt
@@ -271,7 +303,7 @@ jobs:
- name: Upload to GitHub Artifacts - beta.apk-sha256.txt
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.x8bit.bitwarden.beta.apk-sha256.txt
path: ./com.x8bit.bitwarden.beta.apk-sha256.txt
@@ -279,7 +311,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.x8bit.bitwarden.aab-sha256.txt
path: ./com.x8bit.bitwarden.aab-sha256.txt
@@ -287,7 +319,7 @@ jobs:
- name: Upload to GitHub Artifacts - beta.aab-sha256.txt
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.x8bit.bitwarden.beta.aab-sha256.txt
path: ./com.x8bit.bitwarden.beta.aab-sha256.txt
@@ -295,7 +327,7 @@ jobs:
- name: Upload to GitHub Artifacts - debug.apk-sha256.txt
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
path: ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
@@ -338,6 +370,7 @@ jobs:
name: Publish F-Droid artifacts
needs:
- version
- build
runs-on: ubuntu-24.04
permissions:
id-token: write
@@ -424,7 +457,7 @@ jobs:
keyPassword:$FDROID_BETA_KEY_PASSWORD
- name: Upload to GitHub Artifacts - fdroid.apk
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.x8bit.bitwarden-fdroid.apk
path: app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid.apk
@@ -436,14 +469,14 @@ jobs:
> ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
- name: Upload to GitHub Artifacts - fdroid.apk-sha256.txt
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.x8bit.bitwarden-fdroid.apk-sha256.txt
path: ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
if-no-files-found: error
- name: Upload to GitHub Artifacts - beta.fdroid.apk
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.x8bit.bitwarden.beta-fdroid.apk
path: app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden.beta-fdroid.apk
@@ -455,7 +488,7 @@ jobs:
> ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
- name: Upload to GitHub Artifacts - beta.fdroid.apk-sha256.txt
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
path: ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt

View File

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

View File

@@ -1,37 +0,0 @@
name: SDLC / Update Linked Issues on Release
run-name: ${{ inputs.dry-run && '(Dry Run) ' || '' }}Update Linked Issues on Release - ${{ github.event.release.name || inputs.release_url }}
on:
release:
types: [published]
workflow_dispatch:
inputs:
release_url:
description: 'Release URL (e.g. https://github.com/owner/repo/releases/tag/v1.0.0)'
required: true
dry-run:
description: 'Dry run'
type: boolean
default: false
permissions:
contents: read
issues: write
jobs:
update-linked-issues:
name: Update Linked Issues
runs-on: ubuntu-24.04
steps:
- name: Check out repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Update Linked Issues
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
_RELEASE_URL: ${{ github.event.release.html_url || inputs.release_url }}
_DRY_RUN: ${{ inputs.dry-run && '--dry-run' || '' }}
run: |
python3 .github/scripts/gh_release_update_issues.py "$_RELEASE_URL" $_DRY_RUN

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

@@ -99,7 +99,7 @@ jobs:
disable_search: true
- name: Upload test reports
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: always()
with:
name: test-reports-${{ matrix.group }}

View File

@@ -3,13 +3,13 @@ GEM
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.8.9)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1231.0)
aws-sdk-core (3.244.0)
aws-partitions (1.1213.0)
aws-sdk-core (3.242.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.121.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.217.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-s3 (1.213.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
bigdecimal (4.1.0)
bigdecimal (4.0.1)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@@ -68,10 +68,10 @@ GEM
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.4)
faraday-retry (1.0.3)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.1)
fastimage (2.4.0)
fastlane (2.229.0)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
@@ -148,7 +148,7 @@ GEM
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.6.0)
google-cloud-errors (1.5.0)
google-cloud-storage (1.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
@@ -169,7 +169,7 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.19.3)
json (2.18.1)
jwt (2.10.2)
base64
logger (1.7.0)
@@ -185,13 +185,13 @@ GEM
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
public_suffix (7.0.5)
public_suffix (7.0.2)
rake (13.3.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.4.1)
retriable (3.1.2)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)

View File

@@ -297,7 +297,6 @@ dependencies {
standardImplementation(libs.google.firebase.cloud.messaging)
standardImplementation(platform(libs.google.firebase.bom))
standardImplementation(libs.google.firebase.crashlytics)
standardImplementation(libs.google.billing)
standardImplementation(libs.google.play.review)
// Pull in test fixtures from other modules

View File

@@ -1,22 +0,0 @@
package com.x8bit.bitwarden.data.billing.manager
import android.content.Context
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* F-Droid implementation of [PlayBillingManager]. Always returns `true` since
* F-Droid users are eligible for the Premium upgrade flow.
*/
@OmitFromCoverage
@Suppress("UnusedParameter")
class PlayBillingManagerImpl(
context: Context,
dispatcherManager: DispatcherManager,
) : PlayBillingManager {
override val isInAppBillingSupportedFlow: StateFlow<Boolean> =
MutableStateFlow(true)
}

View File

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

View File

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

View File

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

View File

@@ -46,7 +46,6 @@ import com.x8bit.bitwarden.ui.platform.model.FeatureFlagsState
import com.x8bit.bitwarden.ui.platform.util.isAccountSecurityShortcut
import com.x8bit.bitwarden.ui.platform.util.isMyVaultShortcut
import com.x8bit.bitwarden.ui.platform.util.isPasswordGeneratorShortcut
import com.x8bit.bitwarden.ui.platform.util.isPremiumCheckoutCallback
import com.x8bit.bitwarden.ui.vault.util.getTotpDataOrNull
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.FlowPreview
@@ -198,7 +197,6 @@ class MainViewModel @Inject constructor(
is MainAction.SsoResult -> handleSsoResult(action)
is MainAction.WebAuthnResult -> handleWebAuthnResult(action)
is MainAction.CookieAcquisitionResult -> handleCookieAcquisitionResult(action)
is MainAction.PremiumCheckoutResult -> handlePremiumCheckoutResult()
is MainAction.Internal -> handleInternalAction(action)
}
}
@@ -248,11 +246,6 @@ class MainViewModel @Inject constructor(
)
}
private fun handlePremiumCheckoutResult() {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.PremiumCheckoutResult
}
private fun handleAppResumeDataUpdated(action: MainAction.ResumeScreenDataReceived) {
when (val data = action.screenResumeData) {
null -> appResumeManager.clearResumeScreen()
@@ -340,7 +333,6 @@ class MainViewModel @Inject constructor(
val hasGeneratorShortcut = intent.isPasswordGeneratorShortcut
val hasVaultShortcut = intent.isMyVaultShortcut
val hasAccountSecurityShortcut = intent.isAccountSecurityShortcut
val hasPremiumCheckoutCallback = intent.isPremiumCheckoutCallback
val completeRegistrationData = intent.getCompleteRegistrationDataIntentOrNull()
val importCredentialsRequest = intent.getProviderImportCredentialsRequest()
val credentialProviderRequest =
@@ -402,11 +394,6 @@ class MainViewModel @Inject constructor(
)
}
hasPremiumCheckoutCallback -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.PremiumCheckoutResult
}
hasGeneratorShortcut -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.GeneratorShortcut
@@ -561,13 +548,6 @@ sealed class MainAction {
val cookieCallbackResult: AuthTabIntent.AuthResult,
) : MainAction()
/**
* Receive the result from the premium checkout flow.
*/
data class PremiumCheckoutResult(
val authResult: AuthTabIntent.AuthResult,
) : MainAction()
/**
* Receive first Intent by the application.
*/

View File

@@ -124,16 +124,6 @@ interface AuthDiskSource : AppIdProvider {
*/
fun storeUserKey(userId: String, userKey: String?)
/**
* Retrieves the local user data key for the given [userId].
*/
fun getLocalUserDataKey(userId: String): String?
/**
* Stores the local user data key for a given [userId].
*/
fun storeLocalUserDataKey(userId: String, wrappedKey: String?)
/**
* Retrieves a private key using a [userId].
*/

View File

@@ -35,7 +35,6 @@ private const val REMEMBERED_ORG_IDENTIFIER_KEY = "rememberedOrgIdentifier"
private const val STATE_KEY = "state"
private const val INVALID_UNLOCK_ATTEMPTS_KEY = "invalidUnlockAttempts"
private const val MASTER_KEY_ENCRYPTION_USER_KEY = "masterKeyEncryptedUserKey"
private const val LOCAL_USER_DATA_KEY = "localUserDataKey"
private const val MASTER_KEY_ENCRYPTION_PRIVATE_KEY = "encPrivateKey"
private const val PIN_PROTECTED_USER_KEY_KEY = "pinKeyEncryptedUserKey"
private const val PIN_PROTECTED_USER_KEY_KEY_ENVELOPE = "pinKeyEncryptedUserKeyEnvelope"
@@ -145,7 +144,6 @@ class AuthDiskSourceImpl(
override fun clearData(userId: String) {
storeInvalidUnlockAttempts(userId = userId, invalidUnlockAttempts = null)
storeUserKey(userId = userId, userKey = null)
storeLocalUserDataKey(userId = userId, wrappedKey = null)
storeUserAutoUnlockKey(userId = userId, userAutoUnlockKey = null)
storePrivateKey(userId = userId, privateKey = null)
storeAccountKeys(userId = userId, accountKeys = null)
@@ -239,13 +237,6 @@ class AuthDiskSourceImpl(
)
}
override fun getLocalUserDataKey(userId: String): String? =
getString(key = LOCAL_USER_DATA_KEY.appendIdentifier(userId))
override fun storeLocalUserDataKey(userId: String, wrappedKey: String?) {
putString(key = LOCAL_USER_DATA_KEY.appendIdentifier(userId), value = wrappedKey)
}
@Deprecated("Use getAccountKeys instead.", replaceWith = ReplaceWith("getAccountKeys"))
override fun getPrivateKey(userId: String): String? =
getString(key = MASTER_KEY_ENCRYPTION_PRIVATE_KEY.appendIdentifier(userId))

View File

@@ -37,12 +37,12 @@ data class AccountJson(
*
* @property userId The ID of the user.
* @property email The user's email address.
* @property isEmailVerified Whether the user's email is verified.
* @property isTwoFactorEnabled If the profile has two-factor authentication enabled.
* @property isEmailVerified Whether or not the user's email is verified.
* @property isTwoFactorEnabled If the profile has two factor authentication enabled.
* @property name The user's name (if applicable).
* @property stamp The account's security stamp (if applicable).
* @property organizationId The ID of the associated organization (if applicable).
* @property hasPremium True if the user has a Premium account.
* @property hasPremium True if the user has a premium account.
* @property avatarColorHex Hex color value for a user's avatar in the "#AARRGGBB" format.
* @property forcePasswordResetReason Describes the reason for a forced password reset.
* @property kdfType The KDF type.

View File

@@ -136,7 +136,7 @@ interface AuthRepository :
val organizations: List<Organization>
/**
* Whether the welcome carousel should be displayed, based on the feature flag and
* Whether or not the welcome carousel should be displayed, based on the feature flag and
* whether the user has ever logged in or created an account before.
*/
val showWelcomeCarousel: Boolean

View File

@@ -1,10 +1,8 @@
package com.x8bit.bitwarden.data.auth.repository.model
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.ui.platform.base.util.toHexColorRepresentation
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import java.time.Instant
/**
* Represents the overall "user state" of the current active user as well as any users that may be
@@ -42,10 +40,10 @@ data class UserState(
* @property name The user's name (if applicable).
* @property avatarColorHex Hex color value for a user's avatar in the "#AARRGGBB" format.
* @property environment The [Environment] associated with the user's account.
* @property isPremium `true` if the account has a Premium membership.
* @property isPremium `true` if the account has a premium membership.
* @property isLoggedIn `true` if the account is logged in, or `false` if it requires additional
* authentication to view their vault.
* @property isVaultUnlocked Whether the user's vault is currently unlocked.
* @property isVaultUnlocked Whether or not the user's vault is currently unlocked.
* @property needsPasswordReset If the user needs to reset their password.
* @property needsMasterPassword Indicates whether the user needs to create a password (e.g.
* they logged in using SSO and don't yet have one). NOTE: This should **not** be used to
@@ -57,7 +55,6 @@ data class UserState(
* user's vault is enabled.
* @property vaultUnlockType The mechanism by which the user's vault may be unlocked.
* @property isUsingKeyConnector Indicates if the account is currently using a key connector.
* @property creationDate The date the account was created, if available.
*/
data class Account(
val userId: String,
@@ -79,7 +76,6 @@ data class UserState(
val onboardingStatus: OnboardingStatus,
val firstTimeState: FirstTimeState,
val isExportable: Boolean,
val creationDate: Instant?,
) {
/**
* Indicates that the user does or does not have a means to manually unlock the vault.
@@ -100,33 +96,4 @@ data class UserState(
val hasLoginApprovingDevice: Boolean,
val hasResetPasswordPermission: Boolean,
)
@Suppress("UndocumentedPublicClass")
companion object {
/**
* A basic empty account model.
*/
val EMPTY_ACCOUNT: Account = Account(
userId = "",
name = null,
email = "",
avatarColorHex = "".toHexColorRepresentation(),
environment = Environment.Us,
isPremium = false,
isLoggedIn = false,
isVaultUnlocked = false,
needsPasswordReset = false,
organizations = emptyList(),
isBiometricsEnabled = false,
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
needsMasterPassword = false,
hasMasterPassword = true,
trustedDevice = null,
isUsingKeyConnector = false,
onboardingStatus = OnboardingStatus.COMPLETE,
firstTimeState = FirstTimeState(),
isExportable = false,
creationDate = null,
)
}
}

View File

@@ -248,7 +248,6 @@ fun UserStateJson.toUserState(
firstTimeState = firstTimeState,
isExportable = !hasPersonalOwnershipRestrictedOrg &&
!hasPersonalVaultExportRestrictedOrg,
creationDate = profile.creationDate,
)
},
hasPendingAccountAddition = hasPendingAccountAddition,

View File

@@ -3,7 +3,7 @@ package com.x8bit.bitwarden.data.autofill.accessibility.manager
import kotlinx.coroutines.flow.StateFlow
/**
* A container for values specifying whether the accessibility service is enabled.
* A container for values specifying whether or not the accessibility service is enabled.
*/
interface AccessibilityEnabledManager {
/**

View File

@@ -8,7 +8,6 @@ import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofi
import com.x8bit.bitwarden.data.autofill.model.browser.BrowserThirdPartyAutofillStatus
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
/**
* Primary implementation of [AutofillActivityManager].
@@ -21,34 +20,10 @@ class AutofillActivityManagerImpl(
lifecycleScope: LifecycleCoroutineScope,
browserThirdPartyAutofillEnabledManager: BrowserThirdPartyAutofillEnabledManager,
) : AutofillActivityManager {
private val autofillManagerIsEnabled: Boolean
get() = try {
autofillManager.isEnabled
} catch (@Suppress("TooGenericExceptionCaught") e: RuntimeException) {
Timber.e(e, "autofillManager.isEnabled failed")
false
}
private val autofillManagerHasEnabledAutofillServices: Boolean
get() = try {
autofillManager.hasEnabledAutofillServices()
} catch (@Suppress("TooGenericExceptionCaught") e: RuntimeException) {
Timber.e(e, "autofillManager.hasEnabledAutofillServices() failed")
false
}
private val autofillManagerIsAutofillSupported: Boolean
get() = try {
autofillManager.isAutofillSupported
} catch (@Suppress("TooGenericExceptionCaught") e: RuntimeException) {
Timber.e(e, "autofillManager.isAutofillSupported() failed")
false
}
private val isAutofillEnabledAndSupported: Boolean
get() = autofillManagerIsEnabled &&
autofillManagerHasEnabledAutofillServices &&
autofillManagerIsAutofillSupported
get() = autofillManager.isEnabled &&
autofillManager.hasEnabledAutofillServices() &&
autofillManager.isAutofillSupported
private val browserAutofillStatus: BrowserThirdPartyAutofillStatus
get() = BrowserThirdPartyAutofillStatus(

View File

@@ -3,12 +3,12 @@ package com.x8bit.bitwarden.data.autofill.manager
import kotlinx.coroutines.flow.StateFlow
/**
* A container for values specifying whether autofill is enabled. These values should be
* A container for values specifying whether or not autofill is enabled. These values should be
* filled by an [AutofillActivityManager].
*/
interface AutofillEnabledManager {
/**
* Whether autofill should be considered enabled.
* Whether or not autofill should be considered enabled.
*
* Note that changing this does not enable or disable autofill; it is only an indicator that
* this has occurred elsewhere.

View File

@@ -62,13 +62,11 @@ class BrowserThirdPartyAutofillManagerImpl(
)
var thirdPartyEnabled = false
val isThirdPartyAvailable = cursor
?.use {
?.let {
it.moveToFirst()
thirdPartyEnabled = it
.getColumnIndex(THIRD_PARTY_MODE_COLUMN)
.takeUnless { columnIndex -> columnIndex == -1 }
?.let { columnIndex -> it.getInt(columnIndex) != 0 }
?: false
val columnIndex = it.getColumnIndex(THIRD_PARTY_MODE_COLUMN)
thirdPartyEnabled = it.getInt(columnIndex) != 0
it.close()
true
}
?: false

View File

@@ -14,7 +14,7 @@ sealed class AutofillCipher {
abstract val iconRes: Int
/**
* Whether TOTP is enabled for this cipher.
* Whether or not TOTP is enabled for this cipher.
*/
abstract val isTotpEnabled: Boolean

View File

@@ -1,23 +0,0 @@
package com.x8bit.bitwarden.data.billing.datasource.network.di
import com.bitwarden.network.BitwardenServiceClient
import com.bitwarden.network.service.BillingService
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Provides network dependencies in the billing package.
*/
@Module
@InstallIn(SingletonComponent::class)
object BillingNetworkModule {
@Provides
@Singleton
fun provideBillingService(
bitwardenServiceClient: BitwardenServiceClient,
): BillingService = bitwardenServiceClient.billingService
}

View File

@@ -1,73 +0,0 @@
package com.x8bit.bitwarden.data.billing.di
import android.content.Context
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.network.service.BillingService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.billing.manager.PlayBillingManager
import com.x8bit.bitwarden.data.billing.manager.PlayBillingManagerImpl
import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager
import com.x8bit.bitwarden.data.billing.manager.PremiumStateManagerImpl
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.vault.repository.VaultRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.time.Clock
import javax.inject.Singleton
/**
* Provides billing-related dependencies.
*/
@Module
@InstallIn(SingletonComponent::class)
object BillingModule {
@Provides
@Singleton
fun providePlayBillingManager(
@ApplicationContext context: Context,
dispatcherManager: DispatcherManager,
): PlayBillingManager = PlayBillingManagerImpl(
context = context,
dispatcherManager = dispatcherManager,
)
@Provides
@Singleton
fun provideBillingRepository(
playBillingManager: PlayBillingManager,
billingService: BillingService,
): BillingRepository = BillingRepositoryImpl(
playBillingManager = playBillingManager,
billingService = billingService,
)
@Provides
@Singleton
fun providePremiumStateManager(
authDiskSource: AuthDiskSource,
authRepository: AuthRepository,
billingRepository: BillingRepository,
settingsDiskSource: SettingsDiskSource,
vaultRepository: VaultRepository,
featureFlagManager: FeatureFlagManager,
clock: Clock,
dispatcherManager: DispatcherManager,
): PremiumStateManager = PremiumStateManagerImpl(
authDiskSource = authDiskSource,
authRepository = authRepository,
billingRepository = billingRepository,
settingsDiskSource = settingsDiskSource,
vaultRepository = vaultRepository,
featureFlagManager = featureFlagManager,
clock = clock,
dispatcherManager = dispatcherManager,
)
}

View File

@@ -1,14 +0,0 @@
package com.x8bit.bitwarden.data.billing.manager
import kotlinx.coroutines.flow.StateFlow
/**
* Manages interactions with the Google Play Billing system.
*/
interface PlayBillingManager {
/**
* Emits `true` when in-app billing is supported, or `false` otherwise.
*/
val isInAppBillingSupportedFlow: StateFlow<Boolean>
}

View File

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

View File

@@ -1,123 +0,0 @@
package com.x8bit.bitwarden.data.billing.manager
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.repository.model.DataState
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
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.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import java.time.Clock
import java.time.Duration
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,
billingRepository: BillingRepository,
private val settingsDiskSource: SettingsDiskSource,
vaultRepository: VaultRepository,
featureFlagManager: FeatureFlagManager,
private val clock: Clock,
dispatcherManager: DispatcherManager,
) : PremiumStateManager {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
@OptIn(ExperimentalCoroutinesApi::class)
override val isPremiumUpgradeBannerEligibleFlow: StateFlow<Boolean> =
combine(
authRepository.userStateFlow,
billingRepository.isInAppBillingSupportedFlow,
featureFlagManager.getFeatureFlagFlow(FlagKey.MobilePremiumUpgrade),
authDiskSource.activeUserIdChangesFlow
.flatMapLatest { userId ->
userId
?.let { id ->
settingsDiskSource
.getPremiumUpgradeBannerDismissedFlow(id)
.map { it ?: false }
}
?: flowOf(false)
},
vaultRepository.vaultDataStateFlow,
) { userState,
isInAppBillingSupported,
featureFlagEnabled,
isDismissed,
vaultDataState,
->
val activeAccount = userState?.activeAccount
?: return@combine false
val isPremium = activeAccount.isPremium
val isAccountOldEnough = activeAccount.creationDate.isOlderThanDays(
days = PREMIUM_UPGRADE_MINIMUM_ACCOUNT_AGE_DAYS,
clock = clock,
)
val itemCount = vaultDataState.activeVaultItemCount()
!isPremium &&
isInAppBillingSupported &&
featureFlagEnabled &&
!isDismissed &&
isAccountOldEnough &&
itemCount >= PREMIUM_UPGRADE_MINIMUM_VAULT_ITEMS
}
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = false,
)
override fun dismissPremiumUpgradeBanner() {
val activeUserId = authDiskSource.userState?.activeUserId ?: return
settingsDiskSource.storePremiumUpgradeBannerDismissed(
userId = activeUserId,
isDismissed = true,
)
}
}
/**
* Returns `true` if this [Instant] is older than the given number of [days] based on
* the provided [clock]. Returns `false` if the receiver is `null`.
*/
private fun Instant?.isOlderThanDays(days: Long, clock: Clock): Boolean {
this ?: return false
val now = clock.instant()
val ageInDays = Duration.between(this, now).toDays()
return ageInDays >= days
}
/**
* Extracts the count of active (non-deleted, non-archived) vault items from the
* current [DataState].
*/
private fun DataState<VaultData>.activeVaultItemCount(): Int =
data
?.decryptCipherListResult
?.successes
?.count { it.deletedDate == null && it.archivedDate == null }
?: 0
private const val PREMIUM_UPGRADE_MINIMUM_VAULT_ITEMS: Int = 5
private const val PREMIUM_UPGRADE_MINIMUM_ACCOUNT_AGE_DAYS: Long = 7L

View File

@@ -1,26 +0,0 @@
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 kotlinx.coroutines.flow.StateFlow
/**
* Provides an API for managing billing operations.
*/
interface BillingRepository {
/**
* Emits `true` when in-app billing is supported, or `false` otherwise.
*/
val isInAppBillingSupportedFlow: StateFlow<Boolean>
/**
* Creates a Stripe checkout session and returns the checkout URL.
*/
suspend fun getCheckoutSessionUrl(): CheckoutSessionResult
/**
* Retrieves the Stripe customer portal URL for managing the Premium subscription.
*/
suspend fun getPortalUrl(): CustomerPortalResult
}

View File

@@ -1,35 +0,0 @@
package com.x8bit.bitwarden.data.billing.repository
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 kotlinx.coroutines.flow.StateFlow
/**
* The default implementation of [BillingRepository].
*/
class BillingRepositoryImpl(
playBillingManager: PlayBillingManager,
private val billingService: BillingService,
) : BillingRepository {
override val isInAppBillingSupportedFlow: StateFlow<Boolean> =
playBillingManager.isInAppBillingSupportedFlow
override suspend fun getCheckoutSessionUrl(): CheckoutSessionResult =
billingService
.createCheckoutSession()
.fold(
onSuccess = { CheckoutSessionResult.Success(url = it.checkoutSessionUrl) },
onFailure = { CheckoutSessionResult.Error(error = it) },
)
override suspend fun getPortalUrl(): CustomerPortalResult =
billingService
.getPortalUrl()
.fold(
onSuccess = { CustomerPortalResult.Success(url = it.url) },
onFailure = { CustomerPortalResult.Error(error = it) },
)
}

View File

@@ -1,25 +0,0 @@
package com.x8bit.bitwarden.data.billing.repository.model
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
/**
* Models the result of creating a Stripe checkout session.
*/
sealed class CheckoutSessionResult {
/**
* The checkout session URL was successfully retrieved.
*/
data class Success(
val url: String,
) : CheckoutSessionResult()
/**
* Generic error while creating a checkout session. The optional [errorMessage] may be
* displayed directly in the UI when present.
*/
data class Error(
val error: Throwable,
val errorMessage: String? = error.userFriendlyMessage,
) : CheckoutSessionResult()
}

View File

@@ -1,25 +0,0 @@
package com.x8bit.bitwarden.data.billing.repository.model
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
/**
* Models the result of retrieving the Stripe customer portal URL.
*/
sealed class CustomerPortalResult {
/**
* The customer portal URL was successfully retrieved.
*/
data class Success(
val url: String,
) : CustomerPortalResult()
/**
* Generic error while retrieving the customer portal URL. The optional [errorMessage] may
* be displayed directly in the UI when present.
*/
data class Error(
val error: Throwable,
val errorMessage: String? = error.userFriendlyMessage,
) : CustomerPortalResult()
}

View File

@@ -57,7 +57,7 @@ interface BitwardenCredentialManager {
): Fido2CredentialAssertionResult
/**
* Whether the user has authentication attempts remaining.
* Whether or not the user has authentication attempts remaining.
*/
fun hasAuthenticationAttemptsRemaining(): Boolean

View File

@@ -123,24 +123,6 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
*/
fun getIntroducingArchiveActionCardDismissedFlow(userId: String): Flow<Boolean?>
/**
* Retrieves the stored value of whether the Premium upgrade banner has been dismissed.
*/
fun getPremiumUpgradeBannerDismissed(userId: String): Boolean?
/**
* Stores whether the Premium upgrade banner has been dismissed.
*/
fun storePremiumUpgradeBannerDismissed(
userId: String,
isDismissed: Boolean?,
)
/**
* Emits updates that track [getPremiumUpgradeBannerDismissed] for the given [userId].
*/
fun getPremiumUpgradeBannerDismissedFlow(userId: String): Flow<Boolean?>
/**
* Retrieves the biometric integrity validity for the given [userId] and
* [systemBioIntegrityState].
@@ -247,7 +229,7 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
fun storeDefaultUriMatchType(userId: String, uriMatchType: UriMatchType?)
/**
* Gets the value for whether the autofill save prompt should be disabled for the
* Gets the value for whether or not the autofill save prompt should be disabled for the
* given [userId].
*/
fun getAutofillSavePromptDisabled(userId: String): Boolean?
@@ -313,13 +295,13 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
fun getUserHasSignedInPreviously(userId: String): Boolean
/**
* Gets whether the given [userId] has signaled they want to enable autofill in
* Gets whether or not the given [userId] has signalled they want to enable autofill in
* onboarding.
*/
fun getShowBrowserAutofillSettingBadge(userId: String): Boolean?
/**
* Stores the given value for whether the given [userId] has signaled they want to
* Stores the given value for whether or not the given [userId] has signalled they want to
* enable the browser autofill integration in onboarding.
*/
fun storeShowBrowserAutofillSettingBadge(userId: String, showBadge: Boolean?)
@@ -330,13 +312,13 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
fun getShowBrowserAutofillSettingBadgeFlow(userId: String): Flow<Boolean?>
/**
* Gets whether the given [userId] has signaled they want to enable autofill in
* Gets whether or not the given [userId] has signalled they want to enable autofill in
* onboarding.
*/
fun getShowAutoFillSettingBadge(userId: String): Boolean?
/**
* Stores the given value for whether the given [userId] has signaled they want to
* Stores the given value for whether or not the given [userId] has signalled they want to
* enable autofill in onboarding.
*/
fun storeShowAutoFillSettingBadge(userId: String, showBadge: Boolean?)
@@ -347,13 +329,13 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
fun getShowAutoFillSettingBadgeFlow(userId: String): Flow<Boolean?>
/**
* Gets whether the given [userId] has signaled they want to enable unlock options
* Gets whether or not the given [userId] has signalled they want to enable unlock options
* later, during onboarding.
*/
fun getShowUnlockSettingBadge(userId: String): Boolean?
/**
* Stores the given value for whether the given [userId] has signaled they want to
* Stores the given value for whether or not the given [userId] has signalled they want to
* set up unlock options later, during onboarding.
*/
fun storeShowUnlockSettingBadge(userId: String, showBadge: Boolean?)
@@ -364,12 +346,12 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
fun getShowUnlockSettingBadgeFlow(userId: String): Flow<Boolean?>
/**
* Gets whether the given [userId] has signaled they want to import logins later.
* Gets whether or not the given [userId] has signalled they want to import logins later.
*/
fun getShowImportLoginsSettingBadge(userId: String): Boolean?
/**
* Stores the given value for whether the given [userId] has signaled they want to
* Stores the given value for whether or not the given [userId] has signalled they want to
* set import logins later, during first time usage.
*/
fun storeShowImportLoginsSettingBadge(userId: String, showBadge: Boolean?)
@@ -380,13 +362,13 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
fun getShowImportLoginsSettingBadgeFlow(userId: String): Flow<Boolean?>
/**
* Gets whether the application has registered for export via the credential exchange
* Gets whether or not the application has registered for export via the credential exchange
* protocol.
*/
fun getAppRegisteredForExport(): Boolean?
/**
* Stores the given value for whether the application has registered for export via
* Stores the given value for whether or not the application has registered for export via
* the credential exchange protocol.
*/
fun storeAppRegisteredForExport(isRegistered: Boolean?)

View File

@@ -51,8 +51,6 @@ private const val IS_DYNAMIC_COLORS_ENABLED = "isDynamicColorsEnabled"
private const val BROWSER_AUTOFILL_DIALOG_RESHOW_TIME = "browserAutofillDialogReshowTime"
private const val INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED =
"introducingArchiveActionCardDismissed"
private const val PREMIUM_UPGRADE_BANNER_DISMISSED =
"premiumUpgradeBannerDismissed"
/**
* Primary implementation of [SettingsDiskSource].
@@ -94,9 +92,6 @@ class SettingsDiskSourceImpl(
private val mutableIntroducingArchiveActionCardDismissedFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutablePremiumUpgradeBannerDismissedFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableIsIconLoadingDisabledFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableIsCrashLoggingEnabledFlow = bufferedMutableSharedFlow<Boolean?>()
@@ -251,7 +246,6 @@ class SettingsDiskSourceImpl(
// - should show add login coach mark
// - should show generator coach mark
// - should show introducing archive action card dismissed
// - Premium upgrade banner dismissed
}
override fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean? =
@@ -274,26 +268,6 @@ class SettingsDiskSourceImpl(
getMutableIntroducingArchiveActionCardDismissedFlow(userId = userId)
.onSubscription { emit(getIntroducingArchiveActionCardDismissed(userId = userId)) }
override fun getPremiumUpgradeBannerDismissed(userId: String): Boolean? =
getBoolean(
key = PREMIUM_UPGRADE_BANNER_DISMISSED.appendIdentifier(identifier = userId),
)
override fun storePremiumUpgradeBannerDismissed(
userId: String,
isDismissed: Boolean?,
) {
putBoolean(
key = PREMIUM_UPGRADE_BANNER_DISMISSED.appendIdentifier(identifier = userId),
value = isDismissed,
)
getMutablePremiumUpgradeBannerDismissedFlow(userId = userId).tryEmit(isDismissed)
}
override fun getPremiumUpgradeBannerDismissedFlow(userId: String): Flow<Boolean?> =
getMutablePremiumUpgradeBannerDismissedFlow(userId = userId)
.onSubscription { emit(getPremiumUpgradeBannerDismissed(userId = userId)) }
override fun getAccountBiometricIntegrityValidity(
userId: String,
systemBioIntegrityState: String,
@@ -638,13 +612,6 @@ class SettingsDiskSourceImpl(
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutablePremiumUpgradeBannerDismissedFlow(
userId: String,
): MutableSharedFlow<Boolean?> =
mutablePremiumUpgradeBannerDismissedFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableLastSyncFlow(
userId: String,
): MutableSharedFlow<Instant?> =

View File

@@ -19,7 +19,8 @@ class FeatureFlagManagerImpl(
override val sdkFeatureFlags: Map<String, Boolean>
get() = mapOf(
CIPHER_KEY_ENCRYPTION_KEY to serverConfigRepository.isCipherKeyEncryptionEnabled,
CIPHER_KEY_ENCRYPTION_KEY to
getCipherKeyEncryptionFlagState(),
)
override fun <T : Any> getFeatureFlagFlow(key: FlagKey<T>): Flow<T> =
@@ -42,13 +43,17 @@ class FeatureFlagManagerImpl(
.serverConfigStateFlow
.value
.getFlagValueOrDefault(key = key)
}
/**
* Get the computed value of the cipher key encryption flag based on server version.
*/
private val ServerConfigRepository.isCipherKeyEncryptionEnabled: Boolean
get() = isServerVersionAtLeast(serverConfigStateFlow.value, CIPHER_KEY_ENC_MIN_SERVER_VERSION)
/**
* Get the computed value of the cipher key encryption flag based on server version and
* remote flag.
*/
private fun getCipherKeyEncryptionFlagState() =
isServerVersionAtLeast(
serverConfigRepository.serverConfigStateFlow.value,
CIPHER_KEY_ENC_MIN_SERVER_VERSION,
) && getFeatureFlag(FlagKey.CipherKeyEncryption)
}
/**
* Extract the value of a [FlagKey] from the [ServerConfig]. If there is an issue with retrieving

View File

@@ -58,19 +58,19 @@ interface FirstTimeActionManager {
val currentOrDefaultUserFirstTimeState: FirstTimeState
/**
* Stores the given value for whether the active user has signaled they want to
* Stores the given value for whether or not the active user has signalled they want to
* set up unlock options later, during onboarding.
*/
fun storeShowUnlockSettingBadge(showBadge: Boolean)
/**
* Stores the given value for whether the active user has signaled they want to
* Stores the given value for whether or not the active user has signalled they want to
* enable the browser autofill integration later, during onboarding.
*/
fun storeShowBrowserAutofillSettingBadge(showBadge: Boolean)
/**
* Stores the given value for whether the active user has signaled they want to
* Stores the given value for whether or not the active user has signalled they want to
* enable autofill later, during onboarding.
*/
fun storeShowAutoFillSettingBadge(showBadge: Boolean)

View File

@@ -1,18 +0,0 @@
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

@@ -2,7 +2,6 @@ package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.platform.manager.model.NotificationLogoutData
import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData
import com.x8bit.bitwarden.data.platform.manager.model.PremiumStatusChangedData
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData
import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderDeleteData
@@ -30,11 +29,6 @@ interface PushManager {
*/
val passwordlessRequestFlow: Flow<PasswordlessRequestData>
/**
* Flow that represents Premium status change notifications.
*/
val premiumStatusChangedFlow: Flow<PremiumStatusChangedData>
/**
* Flow that represents requests intended to trigger a sync cipher delete.
*/

View File

@@ -14,7 +14,6 @@ import com.x8bit.bitwarden.data.platform.manager.model.NotificationLogoutData
import com.x8bit.bitwarden.data.platform.manager.model.NotificationPayload
import com.x8bit.bitwarden.data.platform.manager.model.NotificationType
import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData
import com.x8bit.bitwarden.data.platform.manager.model.PremiumStatusChangedData
import com.x8bit.bitwarden.data.platform.manager.model.PushNotificationLogOutReason
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData
@@ -64,8 +63,6 @@ class PushManagerImpl @Inject constructor(
private val mutableLogoutSharedFlow = bufferedMutableSharedFlow<NotificationLogoutData>()
private val mutablePasswordlessRequestSharedFlow =
bufferedMutableSharedFlow<PasswordlessRequestData>()
private val mutablePremiumStatusChangedSharedFlow =
bufferedMutableSharedFlow<PremiumStatusChangedData>()
private val mutableSyncCipherDeleteSharedFlow =
bufferedMutableSharedFlow<SyncCipherDeleteData>()
private val mutableSyncCipherUpsertSharedFlow =
@@ -89,9 +86,6 @@ class PushManagerImpl @Inject constructor(
override val passwordlessRequestFlow: SharedFlow<PasswordlessRequestData>
get() = mutablePasswordlessRequestSharedFlow.asSharedFlow()
override val premiumStatusChangedFlow: SharedFlow<PremiumStatusChangedData>
get() = mutablePremiumStatusChangedSharedFlow.asSharedFlow()
override val syncCipherDeleteFlow: SharedFlow<SyncCipherDeleteData>
get() = mutableSyncCipherDeleteSharedFlow.asSharedFlow()
@@ -201,12 +195,6 @@ class PushManagerImpl @Inject constructor(
}
}
NotificationType.POLICY_CHANGED -> {
activeUserId?.let {
mutableFullSyncSharedFlow.tryEmit(it)
}
}
NotificationType.SYNC_CIPHER_DELETE,
NotificationType.SYNC_LOGIN_DELETE,
-> {
@@ -317,25 +305,6 @@ class PushManagerImpl @Inject constructor(
}
?.let { mutableSyncSendDeleteSharedFlow.tryEmit(it) }
}
NotificationType.PREMIUM_STATUS_CHANGED -> {
json
.decodeFromString<NotificationPayload.PremiumStatusChangedNotification>(
string = notification.payload,
)
.takeIf { it.userId != null && it.isPremium != null }
?.let { payload ->
mutablePremiumStatusChangedSharedFlow.tryEmit(
PremiumStatusChangedData(
userId = requireNotNull(payload.userId),
isPremium = requireNotNull(payload.isPremium),
),
)
mutableFullSyncSharedFlow.tryEmit(
requireNotNull(payload.userId),
)
}
}
}
}

View File

@@ -1,7 +1,7 @@
package com.x8bit.bitwarden.data.platform.manager
/**
* Responsible for managing whether the app review prompt should be shown.
* Responsible for managing whether or not the app review prompt should be shown.
*/
interface ReviewPromptManager {
/**
@@ -20,7 +20,8 @@ interface ReviewPromptManager {
fun registerCreateSendAction()
/**
* Returns a boolean value indicating whether the user should be prompted to review the app.
* Returns a boolean value indicating whether or not the user should be prompted to
* review the app.
*/
fun shouldPromptForAppReview(): Boolean
}

View File

@@ -26,9 +26,11 @@ class SdkClientManagerImpl(
repository = sdkRepoFactory.getServerCommunicationConfigRepository(),
platformApi = sdkPlatformApiFactory.getServerCommunicationConfigPlatformApi(),
)
platform().state().registerClientManagedRepositories(
repositories = sdkRepoFactory.getRepositories(userId = userId),
)
userId?.let {
platform().state().apply {
registerCipherRepository(sdkRepoFactory.getCipherRepository(userId = it))
}
}
}
},
) : SdkClientManager {

View File

@@ -142,7 +142,7 @@ private fun getMatchingDomains(
* @param cipherListView The cipher to be judged for a match.
* @param resourceCacheManager The [ResourceCacheManager] for fetching cached resources.
* @param defaultUriMatchType The global default [UriMatchType].
* @param isAndroidApp Whether the [matchUri] belongs to an Android app.
* @param isAndroidApp Whether or not the [matchUri] belongs to an Android app.
* @param matchingDomains The set of domains that match the domain of [matchUri].
* @param matchUri The uri that this cipher is being matched to.
*/
@@ -180,7 +180,7 @@ private fun checkForCipherMatch(
*
* @param resourceCacheManager The [ResourceCacheManager] for fetching cached resources.
* @param defaultUriMatchType The global default [UriMatchType].
* @param isAndroidApp Whether the [matchUri] belongs to an Android app.
* @param isAndroidApp Whether or not the [matchUri] belongs to an Android app.
* @param matchingDomains The set of domains that match the domain of [matchUri].
* @param matchUri The uri that this [LoginUriView] is being matched to.
*/

View File

@@ -47,8 +47,6 @@ 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
@@ -338,12 +336,6 @@ object PlatformManagerModule {
thirdPartyAutofillEnabledManager = thirdPartyAutofillEnabledManager,
)
@Provides
@Singleton
fun provideGmsManager(
@ApplicationContext context: Context,
): GmsManager = GmsManagerImpl(context = context)
@Provides
@Singleton
fun provideDatabaseSchemeManager(

View File

@@ -10,7 +10,7 @@ import java.time.Instant
/**
* The payload of a push notification.
*
* Note: The data we receive is not always reliable, so everything is nullable, and we validate the
* Note: The data we receive is not always reliable, so everything is nullable and we validate the
* data in the [PushManager] as necessary.
*/
@OptIn(ExperimentalSerializationApi::class)
@@ -97,13 +97,4 @@ sealed class NotificationPayload {
data class SyncNotification(
@JsonNames("UserId", "userId") override val userId: String?,
) : NotificationPayload()
/**
* A notification payload for Premium status changes.
*/
@Serializable
data class PremiumStatusChangedNotification(
@JsonNames("UserId", "userId") override val userId: String?,
@JsonNames("Premium", "premium") val isPremium: Boolean?,
) : NotificationPayload()
}

View File

@@ -60,12 +60,6 @@ enum class NotificationType {
@SerialName("16")
AUTH_REQUEST_RESPONSE,
@SerialName("25")
POLICY_CHANGED,
@SerialName("27")
PREMIUM_STATUS_CHANGED,
}
@Keep

View File

@@ -135,10 +135,22 @@ sealed class OrganizationEvent {
* folder as required by the organization's personal vault ownership policy.
*/
data class ItemOrganizationAccepted(
override val cipherId: String? = null,
override val organizationId: String,
) : OrganizationEvent() {
override val cipherId: String? = null
override val type: OrganizationEventType
get() = OrganizationEventType.ORGANIZATION_ITEM_ORGANIZATION_ACCEPTED
}
/**
* Tracks when a user chooses to leave an organization instead of migrating their personal
* ciphers to their organization's My Items folder.
*/
data class ItemOrganizationDeclined(
override val cipherId: String? = null,
override val organizationId: String,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.ORGANIZATION_ITEM_ORGANIZATION_DECLINED
}
}

View File

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

View File

@@ -134,13 +134,6 @@ sealed class SpecialCircumstance : Parcelable {
@Parcelize
data object VerificationCodeShortcut : SpecialCircumstance()
/**
* The app was launched via a Premium checkout callback deep link,
* indicating the user is returning from a Stripe checkout session.
*/
@Parcelize
data object PremiumCheckoutResult : SpecialCircumstance()
/**
* The app was launched to select an account to export credentials from.
*/

View File

@@ -9,7 +9,7 @@ import java.time.Instant
* @property cipherId The cipher ID.
* @property revisionDate The cipher's revision date. This is used to determine if the local copy of
* the cipher is out-of-date.
* @property isUpdate Whether this is an update of an existing cipher.
* @property isUpdate Whether or not this is an update of an existing cipher.
*/
data class SyncCipherUpsertData(
val userId: String,

View File

@@ -9,7 +9,7 @@ import java.time.Instant
* @property folderId The folder ID.
* @property revisionDate The folder's revision date. This is used to determine if the local copy of
* the folder is out-of-date.
* @property isUpdate Whether this is an update of an existing folder.
* @property isUpdate Whether or not this is an update of an existing folder.
*/
data class SyncFolderUpsertData(
val userId: String,

View File

@@ -8,8 +8,8 @@ import java.time.Instant
* @property userId The user ID associated with this update.
* @property sendId The send ID.
* @property revisionDate The send's revision date. This is used to determine if the local copy of
* the Send is out-of-date.
* @property isUpdate Whether this is an update of an existing send.
* the send is out-of-date.
* @property isUpdate Whether or not this is an update of an existing send.
*/
data class SyncSendUpsertData(
val userId: String,

View File

@@ -1,7 +1,7 @@
package com.x8bit.bitwarden.data.platform.manager.sdk
import com.bitwarden.core.ClientManagedTokens
import com.bitwarden.sdk.Repositories
import com.bitwarden.sdk.CipherRepository
import com.bitwarden.sdk.ServerCommunicationConfigRepository
/**
@@ -9,9 +9,9 @@ import com.bitwarden.sdk.ServerCommunicationConfigRepository
*/
interface SdkRepositoryFactory {
/**
* Retrieves or creates a [Repositories] for use with the Bitwarden SDK.
* Retrieves or creates a [CipherRepository] for use with the Bitwarden SDK.
*/
fun getRepositories(userId: String?): Repositories
fun getCipherRepository(userId: String): CipherRepository
/**
* Retrieves or creates a [ClientManagedTokens] for use with the Bitwarden SDK.

View File

@@ -2,12 +2,11 @@ package com.x8bit.bitwarden.data.platform.manager.sdk
import com.bitwarden.core.ClientManagedTokens
import com.bitwarden.data.datasource.disk.ConfigDiskSource
import com.bitwarden.sdk.Repositories
import com.bitwarden.sdk.CipherRepository
import com.bitwarden.sdk.ServerCommunicationConfigRepository
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource
import com.x8bit.bitwarden.data.platform.manager.sdk.repository.SdkCipherRepository
import com.x8bit.bitwarden.data.platform.manager.sdk.repository.SdkLocalUserDataKeyStateRepository
import com.x8bit.bitwarden.data.platform.manager.sdk.repository.SdkTokenRepository
import com.x8bit.bitwarden.data.platform.manager.sdk.repository.ServerCommunicationConfigRepositoryImpl
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
@@ -21,15 +20,12 @@ class SdkRepositoryFactoryImpl(
private val configDiskSource: ConfigDiskSource,
private val authDiskSource: AuthDiskSource,
) : SdkRepositoryFactory {
override fun getRepositories(userId: String?): Repositories =
Repositories(
cipher = getSdkCipherRepository(userId = userId),
folder = null,
userKeyState = null,
localUserDataKeyState = SdkLocalUserDataKeyStateRepository(
authDiskSource = authDiskSource,
),
ephemeralPinEnvelopeState = null,
override fun getCipherRepository(
userId: String,
): CipherRepository =
SdkCipherRepository(
userId = userId,
vaultDiskSource = vaultDiskSource,
)
override fun getClientManagedTokens(
@@ -45,10 +41,4 @@ class SdkRepositoryFactoryImpl(
cookieDiskSource = cookieDiskSource,
configDiskSource = configDiskSource,
)
private fun getSdkCipherRepository(
userId: String?,
): SdkCipherRepository? = userId?.let {
SdkCipherRepository(userId = it, vaultDiskSource = vaultDiskSource)
}
}

View File

@@ -40,33 +40,4 @@ class SdkCipherRepository(
cipher = value.toEncryptedNetworkCipherResponse(encryptedFor = userId),
)
}
override suspend fun setBulk(values: Map<String, Cipher>) {
val validEntries = values.filter { (id, cipher) ->
if (id != cipher.id) {
Timber.e(
"SDK Cipher 'setBulk' operation: ID's do not match for '$id'",
)
false
} else {
true
}
}
if (validEntries.isEmpty()) return
vaultDiskSource.saveCiphers(
userId = userId,
ciphers = validEntries.values.map {
it.toEncryptedNetworkCipherResponse(encryptedFor = userId)
},
)
}
override suspend fun removeBulk(keys: List<String>) {
if (keys.isEmpty()) return
vaultDiskSource.deleteSelectedCiphers(userId = userId, cipherIds = keys)
}
override suspend fun removeAll() {
vaultDiskSource.deleteAllCiphers(userId = userId)
}
}

View File

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

View File

@@ -1,49 +0,0 @@
package com.x8bit.bitwarden.data.platform.manager.sdk.repository
import com.bitwarden.core.LocalUserDataKeyState
import com.bitwarden.sdk.LocalUserDataKeyStateRepository
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
/**
* An implementation of a Bitwarden SDK [LocalUserDataKeyStateRepository].
*/
class SdkLocalUserDataKeyStateRepository(
private val authDiskSource: AuthDiskSource,
) : LocalUserDataKeyStateRepository {
override suspend fun get(id: String): LocalUserDataKeyState? {
return authDiskSource
.getLocalUserDataKey(userId = id)
?.let { LocalUserDataKeyState(wrappedKey = it) }
}
override suspend fun has(
id: String,
): Boolean = authDiskSource.getLocalUserDataKey(userId = id) != null
override suspend fun list(): List<LocalUserDataKeyState> =
authDiskSource
.userState
?.accounts
?.mapNotNull { get(id = it.key) }
.orEmpty()
override suspend fun remove(id: String) {
authDiskSource.storeLocalUserDataKey(userId = id, wrappedKey = null)
}
override suspend fun removeAll() {
removeBulk(keys = authDiskSource.userState?.accounts.orEmpty().keys.toList())
}
override suspend fun removeBulk(keys: List<String>) {
keys.forEach { remove(id = it) }
}
override suspend fun set(id: String, value: LocalUserDataKeyState) {
authDiskSource.storeLocalUserDataKey(userId = id, value.wrappedKey)
}
override suspend fun setBulk(values: Map<String, LocalUserDataKeyState>) {
values.forEach { (id, value) -> set(id = id, value = value) }
}
}

View File

@@ -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)
@@ -170,7 +156,6 @@ class AuthenticatorBridgeRepositoryImpl(
method = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = decryptedUserKey,
),
upgradeToken = null,
),
)
.flatMap { result ->

View File

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

View File

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

View File

@@ -121,7 +121,7 @@ interface SettingsRepository : FlightRecorderManager {
var defaultUriMatchType: UriMatchType
/**
* Whether biometric unlocking is enabled for the current user.
* Whether or not biometric unlocking is enabled for the current user.
*/
val isUnlockWithBiometricsEnabled: Boolean
@@ -131,7 +131,7 @@ interface SettingsRepository : FlightRecorderManager {
val isUnlockWithBiometricsEnabledFlow: Flow<Boolean>
/**
* Whether PIN unlocking is enabled for the current user.
* Whether or not PIN unlocking is enabled for the current user.
*/
val isUnlockWithPinEnabled: Boolean
@@ -141,17 +141,17 @@ interface SettingsRepository : FlightRecorderManager {
val isUnlockWithPinEnabledFlow: Flow<Boolean>
/**
* Whether inline autofill is enabled for the current user.
* Whether or not inline autofill is enabled for the current user.
*/
var isInlineAutofillEnabled: Boolean
/**
* Whether the auto copying totp when autofilling is disabled for the current user.
* Whether or not the auto copying totp when autofilling is disabled for the current user.
*/
var isAutoCopyTotpDisabled: Boolean
/**
* Whether the autofill save prompt is disabled for the current user.
* Whether or not the autofill save prompt is disabled for the current user.
*/
var isAutofillSavePromptDisabled: Boolean
@@ -178,12 +178,12 @@ interface SettingsRepository : FlightRecorderManager {
val isAutofillEnabledStateFlow: StateFlow<Boolean>
/**
* Sets whether screen capture is allowed for the current user.
* Sets whether or not screen capture is allowed for the current user.
*/
var isScreenCaptureAllowed: Boolean
/**
* Whether screen capture is allowed for the current user.
* Whether or not screen capture is allowed for the current user.
*/
val isScreenCaptureAllowedStateFlow: StateFlow<Boolean>
@@ -252,16 +252,6 @@ interface SettingsRepository : FlightRecorderManager {
*/
fun dismissIntroducingArchiveActionCard()
/**
* Gets updates for whether the Premium upgrade banner is dismissed.
*/
fun getPremiumUpgradeBannerDismissedFlow(): StateFlow<Boolean>
/**
* Stores that the Premium upgrade banner has been dismissed for the active user.
*/
fun dismissPremiumUpgradeBanner()
/**
* Stores the encrypted user key for biometrics, allowing it to be used to unlock the current
* user's vault.

View File

@@ -523,29 +523,6 @@ class SettingsRepositoryImpl(
}
}
override fun getPremiumUpgradeBannerDismissedFlow(): StateFlow<Boolean> {
val userId = activeUserId ?: return MutableStateFlow(value = false)
return settingsDiskSource
.getPremiumUpgradeBannerDismissedFlow(userId = userId)
.map { it ?: false }
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = settingsDiskSource
.getPremiumUpgradeBannerDismissed(userId = userId)
?: false,
)
}
override fun dismissPremiumUpgradeBanner() {
activeUserId?.let {
settingsDiskSource.storePremiumUpgradeBannerDismissed(
userId = it,
isDismissed = true,
)
}
}
override suspend fun setupBiometricsKey(cipher: Cipher): BiometricsKeyResult {
val userId = activeUserId
?: return BiometricsKeyResult.Error(error = NoActiveUserException())

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

@@ -57,21 +57,6 @@ interface VaultDiskSource {
*/
suspend fun deleteCipher(userId: String, cipherId: String)
/**
* Saves multiple ciphers to the data source for the given [userId].
*/
suspend fun saveCiphers(userId: String, ciphers: List<SyncResponseJson.Cipher>)
/**
* Deletes ciphers with the given [cipherIds] from the data source for the given [userId].
*/
suspend fun deleteSelectedCiphers(userId: String, cipherIds: List<String>)
/**
* Deletes all ciphers from the data source for the given [userId].
*/
suspend fun deleteAllCiphers(userId: String)
/**
* Saves a collection to the data source for the given [userId].
*/
@@ -80,79 +65,54 @@ interface VaultDiskSource {
/**
* Retrieves all collections from the data source for a given [userId].
*/
fun getCollectionsFlow(userId: String): Flow<List<SyncResponseJson.Collection>>
fun getCollections(userId: String): Flow<List<SyncResponseJson.Collection>>
/**
* Retrieves all domains from the data source for a given [userId].
*/
fun getDomainsFlow(userId: String): Flow<SyncResponseJson.Domains?>
fun getDomains(userId: String): Flow<SyncResponseJson.Domains?>
/**
* Deletes a folder from the data source for the given [userId] and [folderId].
*/
suspend fun deleteFolder(userId: String, folderId: String)
/**
* Deletes folders with the given [folderIds] from the data source for the given [userId].
*/
suspend fun deleteSelectedFolders(userId: String, folderIds: List<String>)
/**
* Deletes all folders from the data source for the given [userId].
*/
suspend fun deleteAllFolders(userId: String)
/**
* Saves a folder to the data source for the given [userId].
*/
suspend fun saveFolder(userId: String, folder: SyncResponseJson.Folder)
/**
* Saves multiple folders to the data source for the given [userId].
*/
suspend fun saveFolders(userId: String, folders: List<SyncResponseJson.Folder>)
/**
* Retrieves a folder from the data source for a given [userId] and [folderId].
*/
suspend fun getFolder(userId: String, folderId: String): SyncResponseJson.Folder?
/**
* Retrieves all folders from the data source for a given [userId].
*/
suspend fun getFolders(userId: String): List<SyncResponseJson.Folder>
fun getFolders(userId: String): Flow<List<SyncResponseJson.Folder>>
/**
* Retrieves all folders from the data source for a given [userId].
*/
fun getFoldersFlow(userId: String): Flow<List<SyncResponseJson.Folder>>
/**
* Saves a Send to the data source for the given [userId].
* Saves a send to the data source for the given [userId].
*/
suspend fun saveSend(userId: String, send: SyncResponseJson.Send)
/**
* Deletes a Send from the data source for the given [userId] and [sendId].
* Deletes a send from the data source for the given [userId] and [sendId].
*/
suspend fun deleteSend(userId: String, sendId: String)
/**
* Retrieves all sends from the data source for a given [userId].
*/
fun getSendsFlow(userId: String): Flow<List<SyncResponseJson.Send>>
fun getSends(userId: String): Flow<List<SyncResponseJson.Send>>
/**
* Replaces all [vault] data for a given [userId] with the new `vault`.
*
* This will always cause the [getCiphersFlow], [getCollectionsFlow], and [getFoldersFlow]
* functions to re-emit even if the underlying data has not changed.
* This will always cause the [getCiphersFlow], [getCollections], and [getFolders] functions to
* re-emit even if the underlying data has not changed.
*/
suspend fun replaceVaultData(userId: String, vault: SyncResponseJson)
/**
* Trigger re-emissions from the [getCiphersFlow], [getCollectionsFlow], [getFoldersFlow],
* and [getSendsFlow] functions.
* Trigger re-emissions from the [getCiphersFlow], [getCollections], [getFolders], and [getSends]
* functions.
*/
suspend fun resyncVaultData(userId: String)

View File

@@ -157,32 +157,6 @@ class VaultDiskSourceImpl(
ciphersDao.deleteCipher(userId, cipherId)
}
override suspend fun saveCiphers(
userId: String,
ciphers: List<SyncResponseJson.Cipher>,
) {
ciphersDao.insertCiphers(
ciphers = ciphers.map { cipher ->
CipherEntity(
id = cipher.id,
userId = userId,
hasTotp = cipher.login?.totp != null,
cipherType = json.encodeToString(cipher.type),
cipherJson = json.encodeToString(cipher),
organizationId = cipher.organizationId,
)
},
)
}
override suspend fun deleteSelectedCiphers(userId: String, cipherIds: List<String>) {
ciphersDao.deleteSelectedCiphers(userId = userId, cipherIds = cipherIds)
}
override suspend fun deleteAllCiphers(userId: String) {
ciphersDao.deleteAllCiphers(userId = userId)
}
override suspend fun saveCollection(userId: String, collection: SyncResponseJson.Collection) {
collectionsDao.insertCollection(
collection = CollectionEntity(
@@ -200,13 +174,13 @@ class VaultDiskSourceImpl(
)
}
override fun getCollectionsFlow(
override fun getCollections(
userId: String,
): Flow<List<SyncResponseJson.Collection>> =
merge(
forceCollectionsFlow,
collectionsDao
.getAllCollectionsFlow(userId = userId)
.getAllCollections(userId = userId)
.map { entities ->
entities.map { entity ->
SyncResponseJson.Collection(
@@ -224,9 +198,9 @@ class VaultDiskSourceImpl(
},
)
override fun getDomainsFlow(userId: String): Flow<SyncResponseJson.Domains?> =
override fun getDomains(userId: String): Flow<SyncResponseJson.Domains?> =
domainsDao
.getDomainsFlow(userId)
.getDomains(userId)
.map { entity ->
withContext(dispatcherManager.default) {
entity?.domainsJson?.let { domains ->
@@ -241,14 +215,6 @@ class VaultDiskSourceImpl(
foldersDao.deleteFolder(userId = userId, folderId = folderId)
}
override suspend fun deleteSelectedFolders(userId: String, folderIds: List<String>) {
foldersDao.deleteSelectedFolders(userId = userId, folderIds = folderIds)
}
override suspend fun deleteAllFolders(userId: String) {
foldersDao.deleteAllFolders(userId = userId)
}
override suspend fun saveFolder(userId: String, folder: SyncResponseJson.Folder) {
foldersDao.insertFolder(
folder = FolderEntity(
@@ -260,51 +226,13 @@ class VaultDiskSourceImpl(
)
}
override suspend fun saveFolders(userId: String, folders: List<SyncResponseJson.Folder>) {
foldersDao.insertFolders(
folders = folders.map { folder ->
FolderEntity(
id = folder.id,
userId = userId,
name = folder.name,
revisionDate = folder.revisionDate,
)
},
)
}
override suspend fun getFolder(
userId: String,
folderId: String,
): SyncResponseJson.Folder? =
foldersDao
.getFolder(userId = userId, folderId = folderId)
?.let { folder ->
SyncResponseJson.Folder(
id = folder.id,
name = folder.name,
revisionDate = folder.revisionDate,
)
}
override suspend fun getFolders(userId: String): List<SyncResponseJson.Folder> =
foldersDao
.getAllFolders(userId = userId)
.map { folder ->
SyncResponseJson.Folder(
id = folder.id,
name = folder.name,
revisionDate = folder.revisionDate,
)
}
override fun getFoldersFlow(
override fun getFolders(
userId: String,
): Flow<List<SyncResponseJson.Folder>> =
merge(
forceFolderFlow,
foldersDao
.getAllFoldersFlow(userId = userId)
.getAllFolders(userId = userId)
.map { entities ->
entities.map { entity ->
SyncResponseJson.Folder(
@@ -333,13 +261,13 @@ class VaultDiskSourceImpl(
sendsDao.deleteSend(userId, sendId)
}
override fun getSendsFlow(
override fun getSends(
userId: String,
): Flow<List<SyncResponseJson.Send>> =
merge(
forceSendFlow,
sendsDao
.getAllSendsFlow(userId = userId)
.getAllSends(userId = userId)
.map { entities ->
withContext(context = dispatcherManager.default) {
entities
@@ -449,9 +377,9 @@ class VaultDiskSourceImpl(
override suspend fun resyncVaultData(userId: String) {
coroutineScope {
val deferredCiphers = async { getCiphersFlow(userId = userId).first() }
val deferredCollections = async { getCollectionsFlow(userId = userId).first() }
val deferredFolders = async { getFoldersFlow(userId = userId).first() }
val deferredSends = async { getSendsFlow(userId = userId).first() }
val deferredCollections = async { getCollections(userId = userId).first() }
val deferredFolders = async { getFolders(userId = userId).first() }
val deferredSends = async { getSends(userId = userId).first() }
forceCiphersFlow.tryEmit(deferredCiphers.await())
forceCollectionsFlow.tryEmit(deferredCollections.await())

View File

@@ -12,7 +12,6 @@ import kotlinx.coroutines.flow.Flow
* Provides methods for inserting, retrieving, and deleting ciphers from the database using the
* [CipherEntity].
*/
@Suppress("TooManyFunctions")
@Dao
interface CiphersDao {
@@ -78,13 +77,6 @@ interface CiphersDao {
@Query("DELETE FROM ciphers WHERE user_id = :userId AND id = :cipherId")
suspend fun deleteCipher(userId: String, cipherId: String): Int
/**
* Deletes the stored ciphers associated with the given [userId] whose IDs are in [cipherIds].
* This will return the number of rows deleted by this query.
*/
@Query("DELETE FROM ciphers WHERE user_id = :userId AND id IN (:cipherIds)")
suspend fun deleteSelectedCiphers(userId: String, cipherIds: List<String>): Int
/**
* Deletes all the stored ciphers associated with the given [userId] and then add all new
* [ciphers] to the database. This will return `true` if any changes were made to the database

View File

@@ -31,7 +31,7 @@ interface CollectionsDao {
* Retrieves all collections from the database for a given [userId].
*/
@Query("SELECT * FROM collections WHERE user_id = :userId")
fun getAllCollectionsFlow(userId: String): Flow<List<CollectionEntity>>
fun getAllCollections(userId: String): Flow<List<CollectionEntity>>
/**
* Deletes all the stored collections associated with the given [userId]. This will return the

View File

@@ -23,7 +23,7 @@ interface DomainsDao {
* Retrieves domains from the database for a given [userId].
*/
@Query("SELECT * FROM domains WHERE user_id = :userId")
fun getDomainsFlow(
fun getDomains(
userId: String,
): Flow<DomainsEntity?>

View File

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

View File

@@ -25,7 +25,7 @@ interface SendsDao {
* Retrieves all sends from the database for a given [userId].
*/
@Query("SELECT * FROM sends WHERE user_id = :userId")
fun getAllSendsFlow(
fun getAllSends(
userId: String,
): Flow<List<SendEntity>>

View File

@@ -114,10 +114,7 @@ class Fido2CredentialStoreImpl(
private fun UpdateCipherResult.toCreateCipherResult(): CreateCipherResult =
when (this) {
UpdateCipherResult.Success -> CreateCipherResult.Success
is UpdateCipherResult.Error -> CreateCipherResult.Error(
error = error,
errorMessage = errorMessage,
)
is UpdateCipherResult.Error -> CreateCipherResult.Error(errorMessage, error)
}
/**

View File

@@ -85,7 +85,10 @@ class CipherManagerImpl(
override suspend fun createCipher(cipherView: CipherView): CreateCipherResult {
val userId = activeUserId
?: return CreateCipherResult.Error(error = NoActiveUserException())
?: return CreateCipherResult.Error(
error = NoActiveUserException(),
errorMessage = null,
)
return vaultSdkSource
.encryptCipher(
userId = userId,
@@ -108,7 +111,7 @@ class CipherManagerImpl(
}
}
.fold(
onFailure = { CreateCipherResult.Error(error = it) },
onFailure = { CreateCipherResult.Error(errorMessage = null, error = it) },
onSuccess = {
reviewPromptManager.registerAddCipherAction()
it
@@ -121,7 +124,7 @@ class CipherManagerImpl(
collectionIds: List<String>,
): CreateCipherResult {
val userId = activeUserId
?: return CreateCipherResult.Error(error = NoActiveUserException())
?: return CreateCipherResult.Error(errorMessage = null, error = NoActiveUserException())
return vaultSdkSource
.encryptCipher(
userId = userId,
@@ -154,7 +157,7 @@ class CipherManagerImpl(
}
}
.fold(
onFailure = { CreateCipherResult.Error(error = it) },
onFailure = { CreateCipherResult.Error(errorMessage = null, error = it) },
onSuccess = {
reviewPromptManager.registerAddCipherAction()
it
@@ -166,8 +169,7 @@ class CipherManagerImpl(
cipherId: String,
cipherView: CipherView,
): ArchiveCipherResult {
val userId = activeUserId
?: return ArchiveCipherResult.Error(error = NoActiveUserException())
val userId = activeUserId ?: return ArchiveCipherResult.Error(NoActiveUserException())
return ciphersService
.archiveCipher(cipherId = cipherId)
.flatMap { response ->
@@ -202,8 +204,7 @@ class CipherManagerImpl(
cipherId: String,
cipherView: CipherView,
): UnarchiveCipherResult {
val userId = activeUserId
?: return UnarchiveCipherResult.Error(error = NoActiveUserException())
val userId = activeUserId ?: return UnarchiveCipherResult.Error(NoActiveUserException())
return ciphersService
.unarchiveCipher(cipherId = cipherId)
.flatMap { response ->
@@ -367,7 +368,7 @@ class CipherManagerImpl(
cipherView: CipherView,
): UpdateCipherResult {
val userId = activeUserId
?: return UpdateCipherResult.Error(error = NoActiveUserException())
?: return UpdateCipherResult.Error(errorMessage = null, error = NoActiveUserException())
return vaultSdkSource
.encryptCipher(
userId = userId,
@@ -398,7 +399,7 @@ class CipherManagerImpl(
}
}
.fold(
onFailure = { UpdateCipherResult.Error(error = it) },
onFailure = { UpdateCipherResult.Error(errorMessage = null, error = it) },
onSuccess = { it },
)
}
@@ -801,7 +802,7 @@ class CipherManagerImpl(
if (!shouldUpdate && shouldCheckCollections && organizationId != null) {
// Check if there are any collections in common
shouldUpdate = vaultDiskSource
.getCollectionsFlow(userId = userId)
.getCollections(userId = userId)
.first()
.any { collectionIds?.contains(it.id) == true }
}

View File

@@ -53,8 +53,7 @@ class FolderManagerImpl(
}
override suspend fun createFolder(folderView: FolderView): CreateFolderResult {
val userId = activeUserId
?: return CreateFolderResult.Error(error = NoActiveUserException())
val userId = activeUserId ?: return CreateFolderResult.Error(NoActiveUserException())
return vaultSdkSource
.encryptFolder(userId = userId, folder = folderView)
.flatMap { folderService.createFolder(body = it.toEncryptedNetworkFolder()) }
@@ -69,8 +68,7 @@ class FolderManagerImpl(
}
override suspend fun deleteFolder(folderId: String): DeleteFolderResult {
val userId = activeUserId
?: return DeleteFolderResult.Error(error = NoActiveUserException())
val userId = activeUserId ?: return DeleteFolderResult.Error(NoActiveUserException())
return folderService
.deleteFolder(folderId = folderId)
.onSuccess {
@@ -87,8 +85,10 @@ class FolderManagerImpl(
folderId: String,
folderView: FolderView,
): UpdateFolderResult {
val userId = activeUserId
?: return UpdateFolderResult.Error(error = NoActiveUserException())
val userId = activeUserId ?: return UpdateFolderResult.Error(
errorMessage = null,
error = NoActiveUserException(),
)
return vaultSdkSource
.encryptFolder(userId = userId, folder = folderView)
.flatMap { folder ->
@@ -110,7 +110,7 @@ class FolderManagerImpl(
.fold(
onSuccess = { UpdateFolderResult.Success(it) },
onFailure = {
UpdateFolderResult.Error(error = it)
UpdateFolderResult.Error(errorMessage = null, error = it)
},
)
}
@@ -120,7 +120,7 @@ class FolderManagerImpl(
}
}
},
onFailure = { UpdateFolderResult.Error(error = it) },
onFailure = { UpdateFolderResult.Error(it.message, error = it) },
)
}
@@ -156,7 +156,7 @@ class FolderManagerImpl(
val isUpdate = syncFolderUpsertData.isUpdate
val revisionDate = syncFolderUpsertData.revisionDate
val localFolder = vaultDiskSource
.getFoldersFlow(userId = userId)
.getFolders(userId = userId)
.first()
.find { it.id == folderId }
val isValidCreate = !isUpdate && localFolder == null

View File

@@ -273,7 +273,7 @@ class SendManagerImpl(
val isUpdate = syncSendUpsertData.isUpdate
val revisionDate = syncSendUpsertData.revisionDate
val localSend = vaultDiskSource
.getSendsFlow(userId = userId)
.getSends(userId = userId)
.first()
.find { it.id == sendId }
val isValidCreate = !isUpdate && localSend == null

View File

@@ -35,12 +35,12 @@ interface VaultLockManager {
var isFromLockFlow: Boolean
/**
* Whether the vault is currently locked for the given [userId].
* Whether or not the vault is currently locked for the given [userId].
*/
fun isVaultUnlocked(userId: String): Boolean
/**
* Whether the vault is currently unlocking for the given [userId].
* Whether or not the vault is currently unlocking for the given [userId].
*/
fun isVaultUnlocking(userId: String): Boolean

View File

@@ -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
@@ -193,7 +192,6 @@ class VaultLockManagerImpl(
email = email,
method = initUserCryptoMethod,
userId = userId,
upgradeToken = null,
),
)
.flatMap { result ->
@@ -479,7 +477,7 @@ class VaultLockManagerImpl(
.map { userId -> vaultTimeoutChangesForUserFlow(userId = userId) }
.merge()
}
.launchIn(ioScope)
.launchIn(unconfinedScope)
}
private fun observeUserLogoutResults() {
@@ -712,7 +710,6 @@ class VaultLockManagerImpl(
is InitUserCryptoMethod.DecryptedKey,
is InitUserCryptoMethod.DeviceKey,
is InitUserCryptoMethod.KeyConnector,
is InitUserCryptoMethod.KeyConnectorUrl,
is InitUserCryptoMethod.Pin,
is InitUserCryptoMethod.PinEnvelope,
-> return

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