mirror of
https://github.com/bitwarden/android.git
synced 2026-05-11 10:54:26 -05:00
Compare commits
1 Commits
v2026.2.0-
...
autofill-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0006146952 |
@@ -1,3 +1,27 @@
|
||||
Use the `reviewing-changes` skill to review this pull request.
|
||||
|
||||
The PR branch is already checked out in the current working directory.
|
||||
|
||||
## CRITICAL OUTPUT REQUIREMENTS
|
||||
|
||||
**Summary Format (REQUIRED):**
|
||||
- **Clean PRs (no issues)**: 2-3 lines MAXIMUM
|
||||
- Format: `**Overall Assessment:** APPROVE\n[One sentence]`
|
||||
- Example: "Clean refactoring following established patterns"
|
||||
|
||||
- **PRs with issues**: Verdict + critical issues list (5-10 lines MAX)
|
||||
- Format: `**Overall Assessment:** APPROVE/REQUEST CHANGES\n**Critical Issues:**\n- issue 1\nSee inline comments`
|
||||
- All details go in inline comments with `<details>` tags, NOT in summary
|
||||
|
||||
**NEVER create:**
|
||||
- ❌ Praise sections ("Strengths", "Good Practices", "Excellent X")
|
||||
- ❌ Verbose analysis sections (Architecture Assessment, Technical Review, Code Quality, etc.)
|
||||
- ❌ Tables, statistics, or detailed breakdowns in summary
|
||||
- ❌ Multiple summary sections
|
||||
- ❌ Checkmarks listing everything done correctly
|
||||
|
||||
**Inline Comments:**
|
||||
- Create separate inline comment for each specific issue/recommendation
|
||||
- Use collapsible `<details>` sections for code examples and explanations
|
||||
- Only severity + one-line description visible; all other content collapsed
|
||||
- Track status of previously identified issues if this is a subsequent review
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extraKnownMarketplaces": {
|
||||
"bitwarden-marketplace": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "bitwarden/ai-plugins"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,56 @@
|
||||
---
|
||||
name: reviewing-changes
|
||||
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.
|
||||
version: 2.0.0
|
||||
description: Comprehensive code reviews for Bitwarden Android. Detects change type (dependency update, bug fix, feature, UI, refactoring, infrastructure) and applies appropriate review depth. Validates MVVM patterns, Hilt DI, security requirements, and test coverage per project standards. Use when reviewing pull requests, checking commits, analyzing code changes, or evaluating architectural compliance.
|
||||
---
|
||||
|
||||
# Reviewing Changes - Android Additions
|
||||
|
||||
This skill provides Android-specific workflow additions that complement the base `bitwarden-code-reviewer` agent standards.
|
||||
# Reviewing Changes
|
||||
|
||||
## Instructions
|
||||
|
||||
**IMPORTANT**: Use structured thinking throughout your review process. Plan your analysis in `<thinking>` tags before providing final feedback.
|
||||
**IMPORTANT**: Use structured thinking throughout your review process. Plan your analysis in `<thinking>` tags before providing final feedback. This improves accuracy by 40% according to research.
|
||||
|
||||
### Step 1: Retrieve Additional Details
|
||||
### Step 1: Check for Existing Review Threads
|
||||
|
||||
Always check for existing comment threads to avoid duplicate comments:
|
||||
|
||||
<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?
|
||||
Before creating any comments:
|
||||
1. Is this a fresh review or re-review of the same PR?
|
||||
2. What existing discussion might already exist?
|
||||
3. Which findings should update existing threads vs create new?
|
||||
</thinking>
|
||||
|
||||
Retrieve any additional information linked to the pull request using available tools (JIRA MCP, GitHub API).
|
||||
**Thread Detection Procedure:**
|
||||
|
||||
If pull request title and message do not provide enough context, request additional details from the reviewer:
|
||||
- Link a JIRA ticket
|
||||
- Associate a GitHub issue
|
||||
- Link to another pull request
|
||||
- Add more detail to the PR title or body
|
||||
1. **Fetch existing comment count:**
|
||||
```bash
|
||||
gh pr view <pr-number> --json comments --jq '.comments | length'
|
||||
```
|
||||
|
||||
### Step 2: Detect Change Type with Android Refinements
|
||||
2. **If count = 0:** No existing threads. Skip to Step 2 (all comments will be new).
|
||||
|
||||
3. **If count > 0:** Fetch full comment data to check for existing threads.
|
||||
```bash
|
||||
gh pr view <pr-number> --json comments --jq '.comments[] | {id, path, line, body}' > pr_comments.json
|
||||
```
|
||||
|
||||
4. **Parse existing threads:** Extract file paths, line numbers, and issue summaries from previous review comments.
|
||||
- Build map: `{file:line → {comment_id, issue_summary, resolved}}`
|
||||
- Note which issues already have active discussions
|
||||
|
||||
5. **Matching Strategy (Hybrid Approach):**
|
||||
When you identify an issue to comment on:
|
||||
- **Exact match:** Same file + same line number → existing thread found
|
||||
- **Nearby match:** Same file + line within ±5 → existing thread found
|
||||
- **No match:** Create new inline comment
|
||||
|
||||
6. **Handling Evolved Issues:**
|
||||
- **Issue persists unchanged:** Respond in existing thread with update
|
||||
- **Issue resolved:** Note resolution in thread response (can mark as resolved if supported)
|
||||
- **Issue changed significantly:** Resolve/close old thread, create new comment explaining evolution
|
||||
|
||||
### Step 2: Detect Change Type
|
||||
|
||||
<thinking>
|
||||
Analyze the changeset systematically:
|
||||
@@ -38,13 +60,17 @@ Analyze the changeset systematically:
|
||||
4. What's the risk level of these changes?
|
||||
</thinking>
|
||||
|
||||
Use the base change type detection from the agent, with Android-specific refinements:
|
||||
Analyze the changeset to determine the primary change type:
|
||||
|
||||
**Android-specific patterns:**
|
||||
- **Feature Addition**: New `ViewModel`, new `Repository`, new `@Composable` functions, new `*Screen.kt` files
|
||||
- **UI Refinement**: Changes only in `*Screen.kt`, `*Composable.kt`, `ui/` package files
|
||||
- **Infrastructure**: Changes to `.github/workflows/`, `gradle/`, `build.gradle.kts`, `libs.versions.toml`
|
||||
- **Dependency Update**: Changes only to `libs.versions.toml` or `build.gradle.kts` with version bumps
|
||||
**Detection Rules:**
|
||||
- **Dependency Update**: Only gradle files changed (`libs.versions.toml`, `build.gradle.kts`) with version number modifications
|
||||
- **Bug Fix**: PR/commit title contains "fix", "bug", or issue ID; addresses existing broken behavior
|
||||
- **Feature Addition**: New files, new ViewModels, significant new functionality
|
||||
- **UI Refinement**: Only UI/Compose files changed, layout/styling focus
|
||||
- **Refactoring**: Code restructuring without behavior change, pattern improvements
|
||||
- **Infrastructure**: CI/CD files, Gradle config, build scripts, tooling changes
|
||||
|
||||
If changeset spans multiple types, use the most complex type's checklist.
|
||||
|
||||
### Step 3: Load Appropriate Checklist
|
||||
|
||||
@@ -63,7 +89,7 @@ The checklist provides:
|
||||
- What to check and what to skip
|
||||
- Structured thinking guidance
|
||||
|
||||
### Step 4: Execute Review Following Checklist
|
||||
### Step 4: Execute Review with Structured Thinking
|
||||
|
||||
<thinking>
|
||||
Before diving into details:
|
||||
@@ -76,7 +102,7 @@ Before diving into details:
|
||||
|
||||
Follow the checklist's multi-pass strategy, thinking through each pass systematically.
|
||||
|
||||
### Step 5: Consult Android Reference Materials As Needed
|
||||
### Step 5: Consult Reference Materials As Needed
|
||||
|
||||
Load reference files only when needed for specific questions:
|
||||
|
||||
@@ -89,10 +115,206 @@ Load reference files only when needed for specific questions:
|
||||
- **UI questions** → `reference/ui-patterns.md` (Compose patterns, theming)
|
||||
- **Style questions** → `docs/STYLE_AND_BEST_PRACTICES.md`
|
||||
|
||||
### Step 6: Document Findings
|
||||
|
||||
## 🛑 STOP: Determine Output Format FIRST
|
||||
|
||||
<thinking>
|
||||
Before writing ANY output, answer this critical question:
|
||||
1. Did I find ANY issues (Critical, Important, Suggested, or Questions)?
|
||||
2. If NO issues found → This is a CLEAN PR → Use 2-3 line minimal format and STOP
|
||||
3. If issues found → Use verdict + critical issues list + inline comments format
|
||||
4. NEVER create praise sections or elaborate on correct implementations
|
||||
</thinking>
|
||||
|
||||
**Decision Tree:**
|
||||
|
||||
```
|
||||
Do you have ANY issues to report (Critical/Important/Suggested/Questions)?
|
||||
│
|
||||
├─ NO → CLEAN PR
|
||||
│ └─ Use 2-3 line format:
|
||||
│ "**Overall Assessment:** APPROVE
|
||||
│ [One sentence describing what PR does well]"
|
||||
│ └─ STOP. Do not proceed to detailed format guidance.
|
||||
│
|
||||
└─ YES → PR WITH ISSUES
|
||||
└─ Use minimal summary + inline comments:
|
||||
"**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
**Critical Issues:**
|
||||
- [issue with file:line]
|
||||
See inline comments for details."
|
||||
```
|
||||
|
||||
## Special Case: Clean PRs with No Issues
|
||||
|
||||
When you find NO critical, important, or suggested issues:
|
||||
|
||||
**Minimal Approval Format (REQUIRED):**
|
||||
```
|
||||
**Overall Assessment:** APPROVE
|
||||
|
||||
[One sentence describing what the PR does well]
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
- "Clean refactoring following established patterns"
|
||||
- "Solid bug fix with comprehensive test coverage"
|
||||
- "Well-structured feature implementation meeting all standards"
|
||||
|
||||
**NEVER do this for clean PRs:**
|
||||
- ❌ Multiple sections (Key Strengths, Changes, Code Quality, etc.)
|
||||
- ❌ Listing everything that was done correctly
|
||||
- ❌ Checkmarks for each file or pattern followed
|
||||
- ❌ Elaborate praise or detailed positive analysis
|
||||
- ❌ Tables, statistics, or detailed breakdowns
|
||||
|
||||
**Why brevity matters:**
|
||||
- Respects developer time (quick approval = move forward faster)
|
||||
- Reduces noise in PR conversations
|
||||
- Saves tokens and processing time
|
||||
- Focuses attention on PRs that actually need discussion
|
||||
|
||||
**If you're tempted to write more than 3 lines for a clean PR, STOP. You're doing it wrong.**
|
||||
|
||||
---
|
||||
|
||||
<thinking>
|
||||
Before writing each comment:
|
||||
1. Is this issue Critical, Important, Suggested, or just Acknowledgment?
|
||||
2. Should I ask a question or provide direction?
|
||||
3. What's the rationale I need to explain?
|
||||
4. What code example would make this actionable?
|
||||
5. Is there a documentation reference to include?
|
||||
</thinking>
|
||||
|
||||
**CRITICAL**: Use summary comment + inline comments approach.
|
||||
|
||||
**Review Comment Structure**:
|
||||
- Create ONE summary comment with overall verdict + critical issues list
|
||||
- Create separate inline comment for EACH specific issue on the exact line with full details
|
||||
- Summary directs readers to inline comments ("See inline comments for details")
|
||||
- Do NOT duplicate issue details between summary and inline comments
|
||||
|
||||
### CRITICAL: No Praise-Only Comments
|
||||
|
||||
❌ **NEVER** create inline comments solely for positive feedback
|
||||
❌ **NEVER** create summary sections like "Strengths", "Good Practices", "What Went Well"
|
||||
❌ **NEVER** use inline comments to elaborate on correct implementations
|
||||
|
||||
Focus exclusively on actionable feedback. Reserve comments for issues requiring attention.
|
||||
|
||||
**Inline Comment Format** (REQUIRED: Use `<details>` Tags):
|
||||
|
||||
**MUST use `<details>` tags for ALL inline comments.** Only severity + one-line description should be visible; all other content must be collapsed.
|
||||
|
||||
```
|
||||
[emoji] **[SEVERITY]**: [One-line issue description]
|
||||
|
||||
<details>
|
||||
<summary>Details and fix</summary>
|
||||
|
||||
[Code example or specific fix]
|
||||
|
||||
[Rationale explaining why]
|
||||
|
||||
Reference: [docs link if applicable]
|
||||
</details>
|
||||
```
|
||||
|
||||
**Visibility Rule:**
|
||||
- **Visible:** Severity prefix (emoji + text) + one-line description
|
||||
- **Collapsed in `<details>`:** Code examples, rationale, explanations, references
|
||||
|
||||
**Example inline comment**:
|
||||
```
|
||||
⚠️ **CRITICAL**: Exposes mutable state
|
||||
|
||||
<details>
|
||||
<summary>Details and fix</summary>
|
||||
|
||||
Change `MutableStateFlow<State>` to `StateFlow<State>`:
|
||||
|
||||
\```kotlin
|
||||
private val _state = MutableStateFlow<State>()
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
\```
|
||||
|
||||
Exposing MutableStateFlow allows external mutation, violating MVVM unidirectional data flow.
|
||||
|
||||
Reference: docs/ARCHITECTURE.md#mvvm-pattern
|
||||
</details>
|
||||
```
|
||||
|
||||
**Summary Comment Format (REQUIRED - No Exceptions):**
|
||||
|
||||
When you have issues to report, use this format ONLY:
|
||||
|
||||
```
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- [One-line summary with file:line reference]
|
||||
|
||||
See inline comments for all details.
|
||||
```
|
||||
|
||||
**Maximum Length**: 5-10 lines total, regardless of PR size or complexity.
|
||||
|
||||
**No exceptions for**:
|
||||
- ❌ Large PRs (10+ files)
|
||||
- ❌ Multiple issue domains
|
||||
- ❌ High-severity issues
|
||||
- ❌ Complex changes
|
||||
|
||||
All details belong in inline comments with `<details>` tags, NOT in the summary.
|
||||
|
||||
**Output Format Rules**:
|
||||
|
||||
**What to Include:**
|
||||
- **Inline comments**: Create separate comment for EACH specific issue with full details in `<details>` tag
|
||||
- **Summary comment**: Overall assessment (APPROVE/REQUEST CHANGES) + list of CRITICAL issues only
|
||||
- **Severity levels** (hybrid emoji + text format):
|
||||
- ⚠️ **CRITICAL** (blocking)
|
||||
- 📋 **IMPORTANT** (should fix)
|
||||
- 💡 **SUGGESTED** (nice to have)
|
||||
- ❓ **QUESTION** (seeking clarification)
|
||||
|
||||
**What to Exclude:**
|
||||
- **No duplication**: Never repeat inline comment details in the summary
|
||||
- **No Important/Suggested in summary**: Only CRITICAL blocking issues belong in summary
|
||||
- **No "Good Practices"/"Strengths" sections**: Never include positive commentary sections
|
||||
- **No "Action Items" section**: This duplicates inline comments - avoid entirely
|
||||
- **No verbose analysis**: Keep detailed analysis (compilation status, security review, rollback plans) in inline comments only
|
||||
|
||||
### ❌ Common Anti-Patterns to Avoid
|
||||
|
||||
**DO NOT:**
|
||||
- Create multiple summary sections (Strengths, Recommendations, Test Coverage Status, Architecture Compliance)
|
||||
- Duplicate critical issues in both summary and inline comments
|
||||
- Write elaborate descriptions in summary (details belong in inline comments)
|
||||
- Exceed 5-10 lines for simple PRs
|
||||
- Create inline comments that only provide praise
|
||||
|
||||
**DO:**
|
||||
- Put verdict + critical issue list ONLY in summary
|
||||
- Put ALL details (explanations, code, rationale) in inline comments with `<details>` collapse
|
||||
- Keep summary to 5-10 lines maximum, regardless of PR size or your analysis depth
|
||||
- Focus comments exclusively on actionable issues
|
||||
|
||||
**Visibility Guidelines:**
|
||||
- **Inline comments visible**: Severity + one-line description only
|
||||
- **Inline comments collapsed**: Code examples, rationale, references in `<details>` tag
|
||||
- **Summary visible**: Verdict + critical issues list only
|
||||
|
||||
See `examples/review-outputs.md` for complete examples.
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **Minimal reviews for clean PRs**: 2-3 lines when no issues found (see Step 6 format guidance)
|
||||
- **Issues-focused feedback**: Only comment when there's something actionable; acknowledge good work briefly without elaboration (see priority-framework.md:145-166)
|
||||
- **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
|
||||
- **Efficient reviews**: Use multi-pass strategy, skip what's not relevant
|
||||
- **Android patterns**: Validate MVVM, Hilt DI, Compose conventions, Kotlin idioms
|
||||
- **Constructive tone**: Ask questions for design decisions, explain rationale, focus on code not people
|
||||
- **Efficient reviews**: Use multi-pass strategy, time-box reviews, skip what's not relevant
|
||||
|
||||
@@ -2,27 +2,6 @@
|
||||
|
||||
Well-structured code reviews demonstrating appropriate depth, tone, and formatting for different change types.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
**Format Reference:**
|
||||
- [Quick Format Reference](#quick-format-reference)
|
||||
- [Inline Comment Format](#inline-comment-format-required)
|
||||
- [Summary Comment Format](#summary-comment-format)
|
||||
|
||||
**Examples:**
|
||||
- [Example 1: Clean PR (No Issues)](#example-1-clean-pr-no-issues)
|
||||
- [Example 2: Dependency Update with Breaking Changes](#example-2-dependency-update-with-breaking-changes)
|
||||
- [Example 3: Feature Addition with Critical Issues](#example-3-feature-addition-with-critical-issues)
|
||||
|
||||
**Anti-Patterns:**
|
||||
- [❌ Anti-Patterns to Avoid](#-anti-patterns-to-avoid)
|
||||
- [Problem: Verbose Summary with Multiple Sections](#problem-verbose-summary-with-multiple-sections)
|
||||
- [Problem: Praise-Only Inline Comments](#problem-praise-only-inline-comments)
|
||||
- [Problem: Missing `<details>` Tags](#problem-missing-details-tags)
|
||||
|
||||
**Summary:**
|
||||
- [Summary](#summary)
|
||||
|
||||
---
|
||||
|
||||
## Quick Format Reference
|
||||
@@ -46,11 +25,10 @@ Reference: [docs link if applicable]
|
||||
```
|
||||
|
||||
**Severity Levels:**
|
||||
- ❌ **CRITICAL** - Blocking, must fix (security, crashes, architecture violations)
|
||||
- ⚠️ **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)
|
||||
- ⚠️ **CRITICAL** - Blocking, must fix
|
||||
- 📋 **IMPORTANT** - Should fix
|
||||
- 💡 **SUGGESTED** - Nice to have
|
||||
- ❓ **QUESTION** - Seeking clarification
|
||||
|
||||
### Summary Comment Format
|
||||
|
||||
@@ -103,7 +81,7 @@ See inline comments for migration details.
|
||||
|
||||
**Inline Comment 1** (on `network/api/BitwardenApiService.kt:34`):
|
||||
```markdown
|
||||
❌ **CRITICAL**: API migration required for Retrofit 3.0
|
||||
⚠️ **CRITICAL**: API migration required for Retrofit 3.0
|
||||
|
||||
<details>
|
||||
<summary>Details and fix</summary>
|
||||
@@ -158,7 +136,7 @@ See inline comments for all issues and suggestions.
|
||||
|
||||
**Inline Comment 1** (on `app/vault/unlock/UnlockViewModel.kt:78`):
|
||||
```markdown
|
||||
❌ **CRITICAL**: Exposes mutable state
|
||||
⚠️ **CRITICAL**: Exposes mutable state
|
||||
|
||||
<details>
|
||||
<summary>Details and fix</summary>
|
||||
@@ -182,7 +160,7 @@ Reference: docs/ARCHITECTURE.md#mvvm-pattern
|
||||
|
||||
**Inline Comment 2** (on `data/vault/UnlockRepository.kt:145`):
|
||||
```markdown
|
||||
❌ **CRITICAL**: PIN stored without encryption - SECURITY ISSUE
|
||||
⚠️ **CRITICAL**: PIN stored without encryption - SECURITY ISSUE
|
||||
|
||||
<details>
|
||||
<summary>Details and fix</summary>
|
||||
@@ -210,7 +188,7 @@ Reference: docs/ARCHITECTURE.md#security
|
||||
|
||||
**Inline Comment 3** (on `app/vault/unlock/UnlockViewModel.kt:92`):
|
||||
```markdown
|
||||
⚠️ **IMPORTANT**: Missing error handling test
|
||||
📋 **IMPORTANT**: Missing error handling test
|
||||
|
||||
<details>
|
||||
<summary>Details and fix</summary>
|
||||
@@ -236,7 +214,7 @@ Ensures error flow remains robust across refactorings.
|
||||
|
||||
**Inline Comment 4** (on `app/vault/unlock/UnlockViewModel.kt:105`):
|
||||
```markdown
|
||||
🎨 **SUGGESTED**: Consider rate limiting for PIN attempts
|
||||
💡 **SUGGESTED**: Consider rate limiting for PIN attempts
|
||||
|
||||
<details>
|
||||
<summary>Details and fix</summary>
|
||||
@@ -268,7 +246,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>
|
||||
@@ -378,7 +356,7 @@ This is exactly the right approach for fail-safe security.
|
||||
**What NOT to do:**
|
||||
|
||||
```markdown
|
||||
❌ **CRITICAL**: Missing test coverage for security-critical code
|
||||
⚠️ **CRITICAL**: Missing test coverage for security-critical code
|
||||
|
||||
The `@OmitFromCoverage` annotation excludes this entire class from test coverage.
|
||||
|
||||
@@ -404,7 +382,7 @@ Security-critical code should have the highest test coverage, not be omitted.
|
||||
|
||||
**Correct approach:**
|
||||
```markdown
|
||||
❌ **CRITICAL**: Missing test coverage for security-critical code
|
||||
⚠️ **CRITICAL**: Missing test coverage for security-critical code
|
||||
|
||||
<details>
|
||||
<summary>Details and fix</summary>
|
||||
@@ -444,3 +422,5 @@ Security-critical code should have the highest test coverage, not be omitted.
|
||||
- Praise-only inline comments
|
||||
- Duplication between summary and inline comments
|
||||
- Verbose analysis in summary (belongs in inline comments)
|
||||
|
||||
See `SKILL.md` for complete review guidelines.
|
||||
|
||||
@@ -2,23 +2,6 @@
|
||||
|
||||
Quick reference for Bitwarden Android architectural patterns during code reviews. For comprehensive details, read `docs/ARCHITECTURE.md` and `docs/STYLE_AND_BEST_PRACTICES.md`.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
**Core Patterns:**
|
||||
- [MVVM + UDF Pattern](#mvvm--udf-pattern)
|
||||
- [ViewModel Structure](#viewmodel-structure)
|
||||
- [UI Layer (Compose)](#ui-layer-compose)
|
||||
- [Hilt Dependency Injection](#hilt-dependency-injection)
|
||||
- [ViewModels](#viewmodels)
|
||||
- [Repositories and Managers](#repositories-and-managers)
|
||||
- [Clock/Time Handling](#clocktime-handling)
|
||||
- [Module Organization](#module-organization)
|
||||
- [Error Handling](#error-handling)
|
||||
- [Use Result Types, Not Exceptions](#use-result-types-not-exceptions)
|
||||
- [Quick Checklist](#quick-checklist)
|
||||
|
||||
---
|
||||
|
||||
## MVVM + UDF Pattern
|
||||
|
||||
### ViewModel Structure
|
||||
@@ -211,43 +194,6 @@ abstract class DataModule {
|
||||
|
||||
---
|
||||
|
||||
### Clock/Time Handling
|
||||
|
||||
Time-dependent code must use injected `Clock` rather than direct `Instant.now()` or `DateTime.now()` calls. This follows the same DI principle as other dependencies.
|
||||
|
||||
**✅ GOOD - Injected Clock**:
|
||||
```kotlin
|
||||
// ViewModel with Clock injection
|
||||
class MyViewModel @Inject constructor(
|
||||
private val clock: Clock,
|
||||
) {
|
||||
fun save() {
|
||||
val timestamp = clock.instant()
|
||||
}
|
||||
}
|
||||
|
||||
// Extension function with Clock parameter
|
||||
fun State.getTimestamp(clock: Clock): Instant =
|
||||
existingTime ?: clock.instant()
|
||||
```
|
||||
|
||||
**❌ BAD - Static/direct calls**:
|
||||
```kotlin
|
||||
// Hidden dependency, non-testable
|
||||
val timestamp = Instant.now()
|
||||
val dateTime = DateTime.now()
|
||||
```
|
||||
|
||||
**Key Rules**:
|
||||
- Inject `Clock` via Hilt constructor (like other dependencies)
|
||||
- Pass `Clock` as parameter to extension functions
|
||||
- `Clock` is provided via `CoreModule` as singleton
|
||||
- Enables deterministic testing with `Clock.fixed(...)`
|
||||
|
||||
Reference: `docs/STYLE_AND_BEST_PRACTICES.md#best-practices--time-and-clock-handling`
|
||||
|
||||
---
|
||||
|
||||
## Module Organization
|
||||
|
||||
```
|
||||
@@ -337,7 +283,6 @@ Reference: `docs/ARCHITECTURE.md#error-handling`
|
||||
- [ ] Business logic in Repository, not ViewModel?
|
||||
- [ ] Using Hilt DI (@HiltViewModel, @Inject constructor)?
|
||||
- [ ] Injecting interfaces, not implementations?
|
||||
- [ ] Time-dependent code uses injected `Clock` (not `Instant.now()`)?
|
||||
- [ ] Correct module placement?
|
||||
|
||||
### Error Handling
|
||||
|
||||
@@ -1,28 +1,8 @@
|
||||
# Finding Priority Framework
|
||||
# Issue Priority Framework
|
||||
|
||||
Use this framework to classify findings during code review. Clear prioritization helps authors triage and address issues effectively.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
**Severity Categories:**
|
||||
- [❌ CRITICAL (Blocker - Must Fix Before Merge)](#critical-blocker---must-fix-before-merge)
|
||||
- [⚠️ 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)
|
||||
- [Optional (Acknowledge But Don't Require)](#optional-acknowledge-but-dont-require)
|
||||
|
||||
**Guidelines:**
|
||||
- [Classification Guidelines](#classification-guidelines)
|
||||
- [When Something is Between Categories](#when-something-is-between-categories)
|
||||
- [Context Matters](#context-matters)
|
||||
- [Examples by Change Type](#examples-by-change-type)
|
||||
- [Special Cases](#special-cases)
|
||||
- [Summary](#summary)
|
||||
|
||||
---
|
||||
|
||||
## ❌ **CRITICAL** (Blocker - Must Fix Before Merge)
|
||||
## Critical (Blocker - Must Fix Before Merge)
|
||||
|
||||
These issues **must** be addressed before the PR can be merged. They pose immediate risks to security, stability, or architecture integrity.
|
||||
|
||||
@@ -69,7 +49,7 @@ This violates MVVM encapsulation pattern.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ **IMPORTANT** (Should Fix)
|
||||
## Important (Should Fix)
|
||||
|
||||
These issues should be addressed but don't block merge if there's a compelling reason. They improve code quality, maintainability, or robustness.
|
||||
|
||||
@@ -122,53 +102,7 @@ Fetching items one-by-one in loop. Consider batch fetch to reduce database queri
|
||||
|
||||
---
|
||||
|
||||
## ♻️ **DEBT** (Technical Debt)
|
||||
|
||||
Code that duplicates existing patterns, violates established conventions, or will require rework within 6 months. Introduces technical debt that should be tracked for future cleanup.
|
||||
|
||||
### Duplication
|
||||
- Copy-pasted code blocks across files
|
||||
- Repeated validation or business logic
|
||||
- Multiple implementations of same pattern
|
||||
- Data transformation duplicated in multiple places
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**app/vault/VaultListViewModel.kt:156** - DEBT: Duplicates encryption logic
|
||||
Same encryption pattern exists in VaultRepository.kt:234 and SyncManager.kt:89.
|
||||
Extract to shared EncryptionUtil to reduce maintenance burden.
|
||||
```
|
||||
|
||||
### Convention Violations
|
||||
- Inconsistent error handling approaches within same module
|
||||
- Mixing architectural patterns (MVVM + MVC)
|
||||
- Not following established DI patterns
|
||||
- Deviating from project code style significantly
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**data/auth/AuthRepository.kt:78** - DEBT: Exception-based error handling
|
||||
Project standard is Result<T> for error handling. This uses try-catch with throws.
|
||||
Creates inconsistency and makes testing harder.
|
||||
Reference: docs/ARCHITECTURE.md#error-handling
|
||||
```
|
||||
|
||||
### Future Rework Required
|
||||
- Hardcoded values that should be configurable
|
||||
- Temporary workarounds without TODO/FIXME
|
||||
- Code that will need changes when planned features arrive
|
||||
- Tight coupling that prevents future extensibility
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**app/settings/SettingsViewModel.kt:45** - DEBT: Hardcoded feature flags
|
||||
Feature flags should come from remote config for A/B testing.
|
||||
Will require rework when experimentation framework launches.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 **SUGGESTED** (Nice to Have)
|
||||
## Suggested (Nice to Have)
|
||||
|
||||
These are improvement opportunities but not required. Consider the effort vs. benefit before requesting changes.
|
||||
|
||||
@@ -208,80 +142,6 @@ Could be extracted to separate validator class for reusability and testing.
|
||||
|
||||
---
|
||||
|
||||
## 💭 **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.
|
||||
|
||||
### Requirements Clarification
|
||||
- Ambiguous acceptance criteria
|
||||
- Multiple valid implementation approaches
|
||||
- Unclear business rules or edge case handling
|
||||
- Conflicting requirements between specs and implementation
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**app/vault/ItemListViewModel.kt:67** - QUESTION: Expected sort behavior for equal timestamps?
|
||||
When items have identical timestamps, should secondary sort be by:
|
||||
- Name (alphabetical)
|
||||
- Creation order
|
||||
- Item type priority
|
||||
|
||||
Spec doesn't specify tie-breaking logic.
|
||||
```
|
||||
|
||||
### Design Decisions
|
||||
- Architecture choices that could go multiple ways
|
||||
- Trade-offs between approaches without clear winner
|
||||
- Feature flag strategy or rollout approach
|
||||
- API design with multiple valid patterns
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**data/sync/SyncManager.kt:134** - QUESTION: Should sync failures retry automatically?
|
||||
Current implementation fails immediately. Options:
|
||||
- Exponential backoff (3 retries)
|
||||
- User-triggered retry only
|
||||
- Background retry on network restore
|
||||
|
||||
What's the expected UX?
|
||||
```
|
||||
|
||||
### System Integration
|
||||
- Unclear contracts with external systems
|
||||
- Potential conflicts with other features/modules
|
||||
- Assumptions about third-party API behavior
|
||||
- Cross-team coordination needs
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**app/auth/BiometricPrompt.kt:89** - QUESTION: Compatibility with pending device credentials PR?
|
||||
PR #1234 is refactoring device credentials. Should this:
|
||||
- Merge first and adapt later
|
||||
- Wait for #1234 to land
|
||||
- Coordinate with that author
|
||||
|
||||
Timing unclear.
|
||||
```
|
||||
|
||||
### Testing Strategy
|
||||
- Uncertainty about test scope or approach
|
||||
- Questions about mocking external dependencies
|
||||
- Edge cases that need product input
|
||||
- Performance testing requirements
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**data/vault/EncryptionTest.kt:45** - QUESTION: Should we test against real Keystore?
|
||||
Currently using mocked Keystore. Real Keystore testing would:
|
||||
+ Catch hardware-specific issues
|
||||
- Slow down CI significantly
|
||||
- Require API 23+ emulators
|
||||
|
||||
What's the priority?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Optional (Acknowledge But Don't Require)
|
||||
|
||||
Note good practices to reinforce positive patterns. Keep these **brief** - list only, no elaboration.
|
||||
@@ -315,26 +175,11 @@ Note good practices to reinforce positive patterns. Keep these **brief** - list
|
||||
- If yes → Critical
|
||||
- If no → Important
|
||||
|
||||
**If unsure between Important and Debt**:
|
||||
- Ask: "Is this a bug/defect or just duplication/inconsistency?"
|
||||
- If bug/defect → Important
|
||||
- If duplication/inconsistency → Debt
|
||||
|
||||
**If unsure between Important and Suggested**:
|
||||
- Ask: "Would I block merge over this?"
|
||||
- If yes → Important
|
||||
- If no → Suggested
|
||||
|
||||
**If unsure between Debt and Suggested**:
|
||||
- Ask: "Will this require rework within 6 months?"
|
||||
- If yes → Debt
|
||||
- If no → Suggested
|
||||
|
||||
**If unsure between Suggested and Question**:
|
||||
- Ask: "Am I requesting a change or asking for clarification?"
|
||||
- If requesting change → Suggested
|
||||
- If seeking clarification → Question
|
||||
|
||||
**If unsure between Suggested and Optional**:
|
||||
- Ask: "Is this actionable feedback or just acknowledgment?"
|
||||
- If actionable → Suggested
|
||||
@@ -425,7 +270,5 @@ Missing tests for refactored helper → SUGGESTED
|
||||
|
||||
**Critical**: Block merge, must fix (security, stability, architecture)
|
||||
**Important**: Should fix before merge (testing, quality, performance)
|
||||
**Debt**: Technical debt introduced, track for future cleanup
|
||||
**Suggested**: Nice to have, consider effort vs benefit
|
||||
**Question**: Seeking clarification on requirements or design
|
||||
**Optional**: Acknowledge good practices, keep brief
|
||||
|
||||
@@ -2,20 +2,6 @@
|
||||
|
||||
Effective code review feedback is clear, actionable, and constructive. This guide provides phrasing patterns for inline comments.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
**Guidelines:**
|
||||
- [Core Directives](#core-directives)
|
||||
- [Phrasing Templates](#phrasing-templates)
|
||||
- [Critical Issues (Prescriptive)](#critical-issues-prescriptive)
|
||||
- [Suggested Improvements (Exploratory)](#suggested-improvements-exploratory)
|
||||
- [Questions (Collaborative)](#questions-collaborative)
|
||||
- [Test Suggestions](#test-suggestions)
|
||||
- [When to Be Prescriptive vs Ask Questions](#when-to-be-prescriptive-vs-ask-questions)
|
||||
- [Special Cases](#special-cases)
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# Testing Android Code Skill
|
||||
|
||||
Quick-reference guide for writing and reviewing tests in the Bitwarden Android codebase.
|
||||
|
||||
## Purpose
|
||||
|
||||
This skill provides tactical testing guidance for Bitwarden-specific patterns. It focuses on base test classes, test utilities, and common gotchas unique to this codebase rather than general testing concepts.
|
||||
|
||||
## When This Skill Activates
|
||||
|
||||
The skill automatically loads when you ask questions like:
|
||||
|
||||
- "How do I test this ViewModel?"
|
||||
- "Why is my Bitwarden test failing?"
|
||||
- "Write tests for this repository"
|
||||
|
||||
Or when you mention terms like: `BaseViewModelTest`, `BitwardenComposeTest`, `stateEventFlow`, `bufferedMutableSharedFlow`, `FakeDispatcherManager`, `createMockCipher`, `asSuccess`
|
||||
|
||||
## What's Included
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SKILL.md` | Core testing patterns and base class locations |
|
||||
| `references/test-base-classes.md` | Detailed base class documentation |
|
||||
| `references/flow-testing-patterns.md` | Turbine patterns for StateFlow/EventFlow |
|
||||
| `references/critical-gotchas.md` | Anti-patterns and debugging tips |
|
||||
| `examples/viewmodel-test-example.md` | Complete ViewModel test example |
|
||||
| `examples/compose-screen-test-example.md` | Complete Compose screen test |
|
||||
| `examples/repository-test-example.md` | Complete repository test with mocks |
|
||||
|
||||
## Patterns Covered
|
||||
|
||||
1. **BaseViewModelTest** - Automatic dispatcher setup with `stateEventFlow()` helper
|
||||
2. **BitwardenComposeTest** - Pre-configured with all managers and theme
|
||||
3. **BaseServiceTest** - MockWebServer setup for network testing
|
||||
4. **Turbine Flow Testing** - StateFlow (replay) vs EventFlow (no replay)
|
||||
5. **Test Data Builders** - 35+ `createMock*` functions with `number: Int` pattern
|
||||
6. **Fake Implementations** - FakeDispatcherManager, FakeConfigDiskSource
|
||||
7. **Result Type Testing** - `.asSuccess()`, `.asFailure()`, `assertCoroutineThrows`
|
||||
|
||||
## Quick Start
|
||||
|
||||
For comprehensive architecture and testing philosophy, see:
|
||||
- `docs/ARCHITECTURE.md`
|
||||
@@ -1,319 +0,0 @@
|
||||
---
|
||||
name: testing-android-code
|
||||
description: This skill should be used when writing or reviewing tests for Android code in Bitwarden. Triggered by "BaseViewModelTest", "BitwardenComposeTest", "BaseServiceTest", "stateEventFlow", "bufferedMutableSharedFlow", "FakeDispatcherManager", "expectNoEvents", "assertCoroutineThrows", "createMockCipher", "createMockSend", "asSuccess", "Why is my Bitwarden test failing?", or testing questions about ViewModels, repositories, Compose screens, or data sources in Bitwarden.
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# Testing Android Code - Bitwarden Testing Patterns
|
||||
|
||||
**This skill provides tactical testing guidance for Bitwarden-specific patterns.** For comprehensive architecture and testing philosophy, consult `docs/ARCHITECTURE.md`.
|
||||
|
||||
## Test Framework Configuration
|
||||
|
||||
**Required Dependencies:**
|
||||
- **JUnit 5** (jupiter), **MockK**, **Turbine** (app.cash.turbine)
|
||||
- **kotlinx.coroutines.test**, **Robolectric**, **Compose Test**
|
||||
|
||||
**Critical Note:** Tests run with en-US locale for consistency. Don't assume other locales.
|
||||
|
||||
---
|
||||
|
||||
## A. ViewModel Testing Patterns
|
||||
|
||||
### Base Class: BaseViewModelTest
|
||||
|
||||
**Always extend `BaseViewModelTest` for ViewModel tests.**
|
||||
|
||||
**Location:** `ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseViewModelTest.kt`
|
||||
|
||||
**Benefits:**
|
||||
- Automatically registers `MainDispatcherExtension` for `UnconfinedTestDispatcher`
|
||||
- Provides `stateEventFlow()` helper for simultaneous StateFlow/EventFlow testing
|
||||
|
||||
**Pattern:**
|
||||
```kotlin
|
||||
class ExampleViewModelTest : BaseViewModelTest() {
|
||||
private val mockRepository: ExampleRepository = mockk()
|
||||
private val savedStateHandle = SavedStateHandle(mapOf(KEY_STATE to INITIAL_STATE))
|
||||
|
||||
@Test
|
||||
fun `ButtonClick should fetch data and update state`() = runTest {
|
||||
coEvery { mockRepository.fetchData(any()) } returns Result.success("data")
|
||||
|
||||
val viewModel = ExampleViewModel(savedStateHandle, mockRepository)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(INITIAL_STATE, awaitItem())
|
||||
viewModel.trySendAction(ExampleAction.ButtonClick)
|
||||
assertEquals(INITIAL_STATE.copy(data = "data"), awaitItem())
|
||||
}
|
||||
|
||||
coVerify { mockRepository.fetchData(any()) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**For complete examples:** See `references/test-base-classes.md`
|
||||
|
||||
### StateFlow vs EventFlow (Critical Distinction)
|
||||
|
||||
| Flow Type | Replay | First Action | Pattern |
|
||||
|-----------|--------|--------------|---------|
|
||||
| StateFlow | Yes (1) | `awaitItem()` gets current state | Expect initial → trigger → expect new |
|
||||
| EventFlow | No | `expectNoEvents()` first | expectNoEvents → trigger → expect event |
|
||||
|
||||
**For detailed patterns:** See `references/flow-testing-patterns.md`
|
||||
|
||||
---
|
||||
|
||||
## B. Compose UI Testing Patterns
|
||||
|
||||
### Base Class: BitwardenComposeTest
|
||||
|
||||
**Always extend `BitwardenComposeTest` for Compose screen tests.**
|
||||
|
||||
**Location:** `app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt`
|
||||
|
||||
**Benefits:**
|
||||
- Pre-configures all Bitwarden managers (FeatureFlags, AuthTab, Biometrics, etc.)
|
||||
- Wraps content in `BitwardenTheme` and `LocalManagerProvider`
|
||||
- Provides fixed Clock for deterministic time-based tests
|
||||
|
||||
**Pattern:**
|
||||
```kotlin
|
||||
class ExampleScreenTest : BitwardenComposeTest() {
|
||||
private var haveCalledNavigateBack = false
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<ExampleEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
setContent {
|
||||
ExampleScreen(
|
||||
onNavigateBack = { haveCalledNavigateBack = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on back click should send BackClick action`() {
|
||||
composeTestRule.onNodeWithContentDescription("Back").performClick()
|
||||
verify { viewModel.trySendAction(ExampleAction.BackClick) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Use `bufferedMutableSharedFlow` for event testing in Compose tests. Default replay is 0; pass `replay = 1` if needed.
|
||||
|
||||
**For complete base class details:** See `references/test-base-classes.md`
|
||||
|
||||
---
|
||||
|
||||
## C. Repository and Service Testing
|
||||
|
||||
### Service Testing with MockWebServer
|
||||
|
||||
**Base Class:** `BaseServiceTest` (`network/src/testFixtures/`)
|
||||
|
||||
```kotlin
|
||||
class ExampleServiceTest : BaseServiceTest() {
|
||||
private val api: ExampleApi = retrofit.create()
|
||||
private val service = ExampleServiceImpl(api)
|
||||
|
||||
@Test
|
||||
fun `getConfig should return success when API succeeds`() = runTest {
|
||||
server.enqueue(MockResponse().setBody(EXPECTED_JSON))
|
||||
val result = service.getConfig()
|
||||
assertEquals(EXPECTED_RESPONSE.asSuccess(), result)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Repository Testing Pattern
|
||||
|
||||
```kotlin
|
||||
class ExampleRepositoryTest {
|
||||
private val fixedClock: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
private val dispatcherManager = FakeDispatcherManager()
|
||||
private val mockDiskSource: ExampleDiskSource = mockk()
|
||||
private val mockService: ExampleService = mockk()
|
||||
|
||||
private val repository = ExampleRepositoryImpl(
|
||||
clock = fixedClock,
|
||||
exampleDiskSource = mockDiskSource,
|
||||
exampleService = mockService,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `fetchData should return success when service succeeds`() = runTest {
|
||||
coEvery { mockService.getData(any()) } returns expectedData.asSuccess()
|
||||
val result = repository.fetchData(userId)
|
||||
assertTrue(result.isSuccess)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key patterns:** Use `FakeDispatcherManager`, fixed Clock, and `.asSuccess()` helpers.
|
||||
|
||||
---
|
||||
|
||||
## D. Test Data Builders
|
||||
|
||||
### Builder Pattern with Number Parameter
|
||||
|
||||
**Location:** `network/src/testFixtures/kotlin/com/bitwarden/network/model/`
|
||||
|
||||
```kotlin
|
||||
fun createMockCipher(
|
||||
number: Int,
|
||||
id: String = "mockId-$number",
|
||||
name: String? = "mockName-$number",
|
||||
// ... more parameters with defaults
|
||||
): SyncResponseJson.Cipher
|
||||
|
||||
// Usage:
|
||||
val cipher1 = createMockCipher(number = 1) // mockId-1, mockName-1
|
||||
val cipher2 = createMockCipher(number = 2) // mockId-2, mockName-2
|
||||
val custom = createMockCipher(number = 3, name = "Custom")
|
||||
```
|
||||
|
||||
**Available Builders (35+):**
|
||||
- **Cipher:** `createMockCipher()`, `createMockLogin()`, `createMockCard()`, `createMockIdentity()`, `createMockSecureNote()`, `createMockSshKey()`, `createMockField()`, `createMockUri()`, `createMockFido2Credential()`, `createMockPasswordHistory()`, `createMockCipherPermissions()`
|
||||
- **Sync:** `createMockSyncResponse()`, `createMockFolder()`, `createMockCollection()`, `createMockPolicy()`, `createMockDomains()`
|
||||
- **Send:** `createMockSend()`, `createMockFile()`, `createMockText()`, `createMockSendJsonRequest()`
|
||||
- **Profile:** `createMockProfile()`, `createMockOrganization()`, `createMockProvider()`, `createMockPermissions()`
|
||||
- **Attachments:** `createMockAttachment()`, `createMockAttachmentJsonRequest()`, `createMockAttachmentResponse()`
|
||||
|
||||
See `network/src/testFixtures/kotlin/com/bitwarden/network/model/` for full list.
|
||||
|
||||
---
|
||||
|
||||
## E. Result Type Testing
|
||||
|
||||
**Locations:**
|
||||
- `.asSuccess()`, `.asFailure()`: `core/src/main/kotlin/com/bitwarden/core/data/util/ResultExtensions.kt`
|
||||
- `assertCoroutineThrows`: `core/src/testFixtures/kotlin/com/bitwarden/core/data/util/TestHelpers.kt`
|
||||
|
||||
```kotlin
|
||||
// Create results
|
||||
"data".asSuccess() // Result.success("data")
|
||||
throwable.asFailure() // Result.failure<T>(throwable)
|
||||
|
||||
// Assertions
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(expectedValue, result.getOrNull())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## F. Test Utilities and Helpers
|
||||
|
||||
### Fake Implementations
|
||||
|
||||
| Fake | Location | Purpose |
|
||||
|------|----------|---------|
|
||||
| `FakeDispatcherManager` | `core/src/testFixtures/` | Deterministic coroutine execution |
|
||||
| `FakeConfigDiskSource` | `data/src/testFixtures/` | In-memory config storage |
|
||||
| `FakeSharedPreferences` | `data/src/testFixtures/` | Memory-backed SharedPreferences |
|
||||
|
||||
### Exception Testing (CRITICAL)
|
||||
|
||||
```kotlin
|
||||
// CORRECT - Call directly, NOT inside runTest
|
||||
@Test
|
||||
fun `test exception`() {
|
||||
assertCoroutineThrows<IllegalStateException> {
|
||||
repository.throwingFunction()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** `runTest` catches exceptions and rethrows them, breaking the assertion pattern.
|
||||
|
||||
---
|
||||
|
||||
## G. Critical Gotchas
|
||||
|
||||
Common testing mistakes in Bitwarden. **For complete details and examples:** See `references/critical-gotchas.md`
|
||||
|
||||
**Core Patterns:**
|
||||
- **assertCoroutineThrows + runTest** - Never wrap in `runTest`; call directly
|
||||
- **Static mock cleanup** - Always `unmockkStatic()` in `@After`
|
||||
- **StateFlow vs EventFlow** - StateFlow: `awaitItem()` first; EventFlow: `expectNoEvents()` first
|
||||
- **FakeDispatcherManager** - Always use instead of real `DispatcherManagerImpl`
|
||||
- **Coroutine test wrapper** - Use `runTest { }` for all Flow/coroutine tests
|
||||
|
||||
**Assertion Patterns:**
|
||||
- **Complete state assertions** - Assert entire state objects, not individual fields
|
||||
- **JUnit over Kotlin** - Use `assertTrue()`, not Kotlin's `assert()`
|
||||
- **Use Result extensions** - Use `asSuccess()` and `asFailure()` for Result type assertions
|
||||
|
||||
**Test Design:**
|
||||
- **Fake vs Mock strategy** - Use Fakes for happy paths, Mocks for error paths
|
||||
- **DI over static mocking** - Extract interfaces (like UuidManager) instead of mockkStatic
|
||||
- **Null stream testing** - Test null returns from ContentResolver operations
|
||||
- **bufferedMutableSharedFlow** - Use with `.onSubscription { emit(state) }` in Fakes
|
||||
- **Test factory methods** - Accept domain state types, not SavedStateHandle
|
||||
|
||||
---
|
||||
|
||||
## H. Test File Organization
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
module/src/test/kotlin/com/bitwarden/.../
|
||||
├── ui/*ScreenTest.kt, *ViewModelTest.kt
|
||||
├── data/repository/*RepositoryTest.kt
|
||||
└── network/service/*ServiceTest.kt
|
||||
|
||||
module/src/testFixtures/kotlin/com/bitwarden/.../
|
||||
├── util/TestHelpers.kt
|
||||
├── base/Base*Test.kt
|
||||
└── model/*Util.kt
|
||||
```
|
||||
|
||||
### Test Naming
|
||||
|
||||
- Classes: `*Test.kt`, `*ScreenTest.kt`, `*ViewModelTest.kt`
|
||||
- Functions: `` `given state when action should result` ``
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Key Bitwarden-specific testing patterns:
|
||||
|
||||
1. **BaseViewModelTest** - Automatic dispatcher setup with `stateEventFlow()` helper
|
||||
2. **BitwardenComposeTest** - Pre-configured with all managers and theme
|
||||
3. **BaseServiceTest** - MockWebServer setup for network testing
|
||||
4. **Turbine Flow Testing** - StateFlow (replay) vs EventFlow (no replay)
|
||||
5. **Test Data Builders** - Consistent `number: Int` parameter pattern
|
||||
6. **Fake Implementations** - FakeDispatcherManager, FakeConfigDiskSource
|
||||
7. **Result Type Testing** - `.asSuccess()`, `.asFailure()`
|
||||
|
||||
**Always consult:** `docs/ARCHITECTURE.md` and existing test files for reference implementations.
|
||||
|
||||
---
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
For detailed information, see:
|
||||
|
||||
- `references/test-base-classes.md` - Detailed base class documentation and usage patterns
|
||||
- `references/flow-testing-patterns.md` - Complete Turbine patterns for StateFlow/EventFlow
|
||||
- `references/critical-gotchas.md` - Full anti-pattern reference and debugging tips
|
||||
|
||||
**Complete Examples:**
|
||||
- `examples/viewmodel-test-example.md` - Full ViewModel test with StateFlow/EventFlow
|
||||
- `examples/compose-screen-test-example.md` - Full Compose screen test
|
||||
- `examples/repository-test-example.md` - Full repository test with mocks and fakes
|
||||
@@ -1,337 +0,0 @@
|
||||
/**
|
||||
* Complete Compose Screen Test Example
|
||||
*
|
||||
* Key patterns demonstrated:
|
||||
* - Extending BitwardenComposeTest
|
||||
* - Mocking ViewModel with flows
|
||||
* - Testing UI interactions
|
||||
* - Testing navigation callbacks
|
||||
* - Using bufferedMutableSharedFlow for events
|
||||
* - Testing dialogs with isDialog() and hasAnyAncestor()
|
||||
*/
|
||||
package com.bitwarden.example.feature
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.filterToOne
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.ui.util.assertNoDialogExists
|
||||
import com.bitwarden.ui.util.isProgressBar
|
||||
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class ExampleScreenTest : BitwardenComposeTest() {
|
||||
|
||||
// Track navigation callbacks
|
||||
private var haveCalledNavigateBack = false
|
||||
private var haveCalledNavigateToNext = false
|
||||
|
||||
// Use bufferedMutableSharedFlow for events (default replay = 0)
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<ExampleEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
|
||||
// Mock ViewModel with relaxed = true
|
||||
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
haveCalledNavigateBack = false
|
||||
haveCalledNavigateToNext = false
|
||||
|
||||
setContent {
|
||||
ExampleScreen(
|
||||
onNavigateBack = { haveCalledNavigateBack = true },
|
||||
onNavigateToNext = { haveCalledNavigateToNext = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Back button sends action to ViewModel
|
||||
*/
|
||||
@Test
|
||||
fun `on back click should send BackClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Back")
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.BackClick) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Submit button sends action to ViewModel
|
||||
*/
|
||||
@Test
|
||||
fun `on submit click should send SubmitClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Submit")
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.SubmitClick) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Loading state shows progress indicator
|
||||
*/
|
||||
@Test
|
||||
fun `loading state should display progress indicator`() {
|
||||
mutableStateFlow.update { it.copy(isLoading = true) }
|
||||
|
||||
composeTestRule
|
||||
.onNode(isProgressBar)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Data state shows content
|
||||
*/
|
||||
@Test
|
||||
fun `data state should display content`() {
|
||||
mutableStateFlow.update { it.copy(data = "Test Data") }
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Test Data")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Error state shows error message
|
||||
*/
|
||||
@Test
|
||||
fun `error state should display error message`() {
|
||||
mutableStateFlow.update { it.copy(errorMessage = "Something went wrong") }
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Something went wrong")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: NavigateBack event triggers navigation callback
|
||||
*/
|
||||
@Test
|
||||
fun `NavigateBack event should call onNavigateBack`() {
|
||||
mutableEventFlow.tryEmit(ExampleEvent.NavigateBack)
|
||||
|
||||
assertTrue(haveCalledNavigateBack)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: NavigateToNext event triggers navigation callback
|
||||
*/
|
||||
@Test
|
||||
fun `NavigateToNext event should call onNavigateToNext`() {
|
||||
mutableEventFlow.tryEmit(ExampleEvent.NavigateToNext)
|
||||
|
||||
assertTrue(haveCalledNavigateToNext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Item in list can be clicked
|
||||
*/
|
||||
@Test
|
||||
fun `on item click should send ItemClick action`() {
|
||||
val itemId = "item-123"
|
||||
mutableStateFlow.update {
|
||||
it.copy(items = listOf(ExampleItem(id = itemId, name = "Test Item")))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Test Item")
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.ItemClick(itemId)) }
|
||||
}
|
||||
|
||||
// ==================== DIALOG TESTS ====================
|
||||
|
||||
/**
|
||||
* Test: No dialog exists when dialogState is null
|
||||
*/
|
||||
@Test
|
||||
fun `no dialog should exist when dialogState is null`() {
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
|
||||
composeTestRule.assertNoDialogExists()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Loading dialog displays when state updates
|
||||
* PATTERN: Use isDialog() to check dialog exists
|
||||
*/
|
||||
@Test
|
||||
fun `loading dialog should display when dialogState is Loading`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = ExampleState.DialogState.Loading("Please wait..."))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNode(isDialog())
|
||||
.assertIsDisplayed()
|
||||
|
||||
// Verify loading text within dialog using hasAnyAncestor(isDialog())
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Please wait...")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Error dialog displays title and message
|
||||
* PATTERN: Use filterToOne(hasAnyAncestor(isDialog())) to find text within dialogs
|
||||
*/
|
||||
@Test
|
||||
fun `error dialog should display title and message`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ExampleState.DialogState.Error(
|
||||
title = "An error has occurred",
|
||||
message = "Something went wrong. Please try again.",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Verify dialog exists
|
||||
composeTestRule
|
||||
.onNode(isDialog())
|
||||
.assertIsDisplayed()
|
||||
|
||||
// Verify title within dialog
|
||||
composeTestRule
|
||||
.onAllNodesWithText("An error has occurred")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
// Verify message within dialog
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Something went wrong. Please try again.")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Dialog button click sends action
|
||||
* PATTERN: Find button with hasAnyAncestor(isDialog()) then performClick()
|
||||
*/
|
||||
@Test
|
||||
fun `error dialog dismiss button should send DismissDialog action`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ExampleState.DialogState.Error(
|
||||
title = "Error",
|
||||
message = "An error occurred",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Click dismiss button within dialog
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Ok")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.DismissDialog) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Confirmation dialog with multiple buttons
|
||||
* PATTERN: Test both confirm and cancel actions
|
||||
*/
|
||||
@Test
|
||||
fun `confirmation dialog confirm button should send ConfirmAction`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ExampleState.DialogState.Confirmation(
|
||||
title = "Confirm Action",
|
||||
message = "Are you sure you want to proceed?",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Click confirm button
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Confirm")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.ConfirmAction) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirmation dialog cancel button should send DismissDialog action`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ExampleState.DialogState.Confirmation(
|
||||
title = "Confirm Action",
|
||||
message = "Are you sure?",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Click cancel button
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Cancel")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.DismissDialog) }
|
||||
}
|
||||
}
|
||||
|
||||
private val DEFAULT_STATE = ExampleState(
|
||||
isLoading = false,
|
||||
data = null,
|
||||
errorMessage = null,
|
||||
items = emptyList(),
|
||||
dialogState = null,
|
||||
)
|
||||
|
||||
// Example types (normally in separate files)
|
||||
data class ExampleState(
|
||||
val isLoading: Boolean = false,
|
||||
val data: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
val items: List<ExampleItem> = emptyList(),
|
||||
val dialogState: DialogState? = null,
|
||||
) {
|
||||
/**
|
||||
* PATTERN: Nested sealed class for dialog states.
|
||||
* Common dialog types: Loading, Error, Confirmation
|
||||
*/
|
||||
sealed class DialogState {
|
||||
data class Loading(val message: String) : DialogState()
|
||||
data class Error(val title: String, val message: String) : DialogState()
|
||||
data class Confirmation(val title: String, val message: String) : DialogState()
|
||||
}
|
||||
}
|
||||
|
||||
data class ExampleItem(val id: String, val name: String)
|
||||
|
||||
sealed class ExampleAction {
|
||||
data object BackClick : ExampleAction()
|
||||
data object SubmitClick : ExampleAction()
|
||||
data class ItemClick(val itemId: String) : ExampleAction()
|
||||
data object DismissDialog : ExampleAction()
|
||||
data object ConfirmAction : ExampleAction()
|
||||
}
|
||||
|
||||
sealed class ExampleEvent {
|
||||
data object NavigateBack : ExampleEvent()
|
||||
data object NavigateToNext : ExampleEvent()
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
/**
|
||||
* Complete Repository Test Example
|
||||
*
|
||||
* Key patterns demonstrated:
|
||||
* - Fake for disk sources, Mock for network services
|
||||
* - Using FakeDispatcherManager for deterministic coroutines
|
||||
* - Using fixed Clock for deterministic time
|
||||
* - Testing Result types with .asSuccess() / .asFailure()
|
||||
* - Asserting actual objects (not isSuccess/isFailure) for better diagnostics
|
||||
* - Testing Flow emissions with Turbine
|
||||
*/
|
||||
package com.bitwarden.example.data.repository
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.core.data.util.asFailure
|
||||
import com.bitwarden.core.data.util.asSuccess
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.onSubscription
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class ExampleRepositoryTest {
|
||||
|
||||
// Fixed clock for deterministic time-based tests
|
||||
private val fixedClock: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
|
||||
// Use FakeDispatcherManager for deterministic coroutine execution
|
||||
private val dispatcherManager = FakeDispatcherManager()
|
||||
|
||||
// Mock service (network layer is always mocked)
|
||||
private val mockService: ExampleService = mockk()
|
||||
|
||||
/**
|
||||
* PATTERN: Use Fake for disk source in happy path tests.
|
||||
* This is the Bitwarden convention for repository testing.
|
||||
*/
|
||||
private val fakeDiskSource = FakeExampleDiskSource()
|
||||
|
||||
private lateinit var repository: ExampleRepositoryImpl
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
repository = ExampleRepositoryImpl(
|
||||
clock = fixedClock,
|
||||
service = mockService,
|
||||
diskSource = fakeDiskSource,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== HAPPY PATH TESTS (use Fake) ====================
|
||||
|
||||
/**
|
||||
* Test: Successful fetch returns data and saves to disk
|
||||
*/
|
||||
@Test
|
||||
fun `fetchData should return success and save to disk when service succeeds`() = runTest {
|
||||
val expectedData = ExampleData(id = "1", name = "Test", updatedAt = fixedClock.instant())
|
||||
coEvery { mockService.getData() } returns expectedData.asSuccess()
|
||||
|
||||
val result = repository.fetchData()
|
||||
|
||||
assertEquals(expectedData, result.getOrThrow())
|
||||
// Fake automatically stores the data - verify it's there
|
||||
assertEquals(expectedData, fakeDiskSource.storedData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Service failure returns failure without saving
|
||||
*/
|
||||
@Test
|
||||
fun `fetchData should return failure when service fails`() = runTest {
|
||||
val exception = Exception("Network error")
|
||||
coEvery { mockService.getData() } returns exception.asFailure()
|
||||
|
||||
val result = repository.fetchData()
|
||||
|
||||
assertEquals(exception, result.exceptionOrNull())
|
||||
// Fake was not updated
|
||||
assertNull(fakeDiskSource.storedData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Repository flow emits when disk source updates
|
||||
*/
|
||||
@Test
|
||||
fun `dataFlow should emit when disk source updates`() = runTest {
|
||||
val data1 = ExampleData(id = "1", name = "First", updatedAt = fixedClock.instant())
|
||||
val data2 = ExampleData(id = "2", name = "Second", updatedAt = fixedClock.instant())
|
||||
|
||||
repository.dataFlow.test {
|
||||
// Initial null value from Fake
|
||||
assertNull(awaitItem())
|
||||
|
||||
// Update via Fake property setter (triggers emission)
|
||||
fakeDiskSource.storedData = data1
|
||||
assertEquals(data1, awaitItem())
|
||||
|
||||
// Another update
|
||||
fakeDiskSource.storedData = data2
|
||||
assertEquals(data2, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Refresh fetches and saves new data
|
||||
*/
|
||||
@Test
|
||||
fun `refresh should fetch new data and update disk source`() = runTest {
|
||||
val newData = ExampleData(id = "new", name = "Fresh", updatedAt = fixedClock.instant())
|
||||
coEvery { mockService.getData() } returns newData.asSuccess()
|
||||
|
||||
val result = repository.refresh()
|
||||
|
||||
assertEquals(Unit, result.getOrThrow())
|
||||
coVerify { mockService.getData() }
|
||||
assertEquals(newData, fakeDiskSource.storedData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Delete clears data from disk
|
||||
*/
|
||||
@Test
|
||||
fun `deleteData should clear disk source`() = runTest {
|
||||
// Pre-populate the fake
|
||||
fakeDiskSource.storedData = ExampleData(id = "1", name = "Test", updatedAt = fixedClock.instant())
|
||||
|
||||
repository.deleteData()
|
||||
|
||||
assertNull(fakeDiskSource.storedData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Cached data returns from disk when available
|
||||
*/
|
||||
@Test
|
||||
fun `getCachedData should return disk data without network call`() = runTest {
|
||||
val cachedData = ExampleData(
|
||||
id = "cached",
|
||||
name = "Cached",
|
||||
updatedAt = fixedClock.instant(),
|
||||
)
|
||||
fakeDiskSource.storedData = cachedData
|
||||
|
||||
val result = repository.getCachedData()
|
||||
|
||||
assertEquals(cachedData, result)
|
||||
coVerify(exactly = 0) { mockService.getData() }
|
||||
}
|
||||
|
||||
// ==================== ERROR PATH TESTS ====================
|
||||
|
||||
/**
|
||||
* PATTERN: For error paths, reconfigure the class-level mock per-test.
|
||||
* Use coEvery to change mock behavior for each specific test case.
|
||||
*/
|
||||
@Test
|
||||
fun `fetchData should return failure when service returns error`() = runTest {
|
||||
val exception = Exception("Server unavailable")
|
||||
coEvery { mockService.getData() } returns exception.asFailure()
|
||||
|
||||
val result = repository.fetchData()
|
||||
|
||||
assertEquals(exception, result.exceptionOrNull())
|
||||
// Fake state unchanged on failure
|
||||
assertNull(fakeDiskSource.storedData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh should return failure and preserve cached data when service fails`() = runTest {
|
||||
// Pre-populate cache via Fake
|
||||
val cachedData = ExampleData(id = "cached", name = "Old", updatedAt = fixedClock.instant())
|
||||
fakeDiskSource.storedData = cachedData
|
||||
|
||||
// Reconfigure mock to return failure
|
||||
coEvery { mockService.getData() } returns Exception("Network error").asFailure()
|
||||
|
||||
val result = repository.refresh()
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
// Cached data preserved on failure
|
||||
assertEquals(cachedData, fakeDiskSource.storedData)
|
||||
}
|
||||
}
|
||||
|
||||
// Example types (normally in separate files)
|
||||
data class ExampleData(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val updatedAt: Instant,
|
||||
)
|
||||
|
||||
interface ExampleService {
|
||||
suspend fun getData(): Result<ExampleData>
|
||||
}
|
||||
|
||||
interface ExampleDiskSource {
|
||||
val dataFlow: kotlinx.coroutines.flow.Flow<ExampleData?>
|
||||
fun getData(): ExampleData?
|
||||
fun saveData(data: ExampleData)
|
||||
fun clearData()
|
||||
}
|
||||
|
||||
/**
|
||||
* PATTERN: Fake implementation for happy path testing.
|
||||
*
|
||||
* Key characteristics:
|
||||
* - Uses bufferedMutableSharedFlow(replay = 1) for proper replay behavior
|
||||
* - Uses .onSubscription { emit(state) } for immediate state emission
|
||||
* - Private storage with override property setter that emits to flow
|
||||
* - Test assertions done via the override property getter
|
||||
*/
|
||||
class FakeExampleDiskSource : ExampleDiskSource {
|
||||
private var storedDataValue: ExampleData? = null
|
||||
private val mutableDataFlow = bufferedMutableSharedFlow<ExampleData?>(replay = 1)
|
||||
|
||||
/**
|
||||
* Override property with getter/setter. Setter emits to flow automatically.
|
||||
* Tests can read this property for assertions and write to trigger emissions.
|
||||
*/
|
||||
var storedData: ExampleData?
|
||||
get() = storedDataValue
|
||||
set(value) {
|
||||
storedDataValue = value
|
||||
mutableDataFlow.tryEmit(value)
|
||||
}
|
||||
|
||||
override val dataFlow: Flow<ExampleData?>
|
||||
get() = mutableDataFlow.onSubscription { emit(storedData) }
|
||||
|
||||
override fun getData(): ExampleData? = storedData
|
||||
|
||||
override fun saveData(data: ExampleData) {
|
||||
storedData = data
|
||||
}
|
||||
|
||||
override fun clearData() {
|
||||
storedData = null
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* Complete ViewModel Test Example
|
||||
*
|
||||
* Key patterns demonstrated:
|
||||
* - Extending BaseViewModelTest
|
||||
* - Testing StateFlow with Turbine
|
||||
* - Testing EventFlow with Turbine
|
||||
* - Using stateEventFlow() for simultaneous testing
|
||||
* - MockK mocking patterns
|
||||
* - Test factory method design (accepts domain state, not SavedStateHandle)
|
||||
* - Complete state assertions (assert entire state objects)
|
||||
*/
|
||||
package com.bitwarden.example.feature
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ExampleViewModelTest : BaseViewModelTest() {
|
||||
|
||||
// Mock dependencies
|
||||
private val mockRepository: ExampleRepository = mockk()
|
||||
private val mockAuthDiskSource: AuthDiskSource = mockk {
|
||||
every { userStateFlow } returns MutableStateFlow(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* StateFlow has replay=1, so first awaitItem() returns current state
|
||||
*/
|
||||
@Test
|
||||
fun `initial state should be default state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test state transitions: initial -> loading -> success
|
||||
*/
|
||||
@Test
|
||||
fun `LoadData action should update state from idle to loading to success`() = runTest {
|
||||
val expectedData = "loaded data"
|
||||
coEvery { mockRepository.fetchData(any()) } returns Result.success(expectedData)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.trySendAction(ExampleAction.LoadData)
|
||||
|
||||
assertEquals(DEFAULT_STATE.copy(isLoading = true), awaitItem())
|
||||
assertEquals(DEFAULT_STATE.copy(isLoading = false, data = expectedData), awaitItem())
|
||||
}
|
||||
|
||||
coVerify { mockRepository.fetchData(any()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* EventFlow has no replay - MUST call expectNoEvents() first
|
||||
*/
|
||||
@Test
|
||||
fun `SubmitClick action should emit NavigateToNext event`() = runTest {
|
||||
coEvery { mockRepository.submitData(any()) } returns Result.success(Unit)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
expectNoEvents() // CRITICAL for EventFlow
|
||||
viewModel.trySendAction(ExampleAction.SubmitClick)
|
||||
assertEquals(ExampleEvent.NavigateToNext, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use stateEventFlow() helper for simultaneous testing
|
||||
*/
|
||||
@Test
|
||||
fun `complex action should update state and emit event`() = runTest {
|
||||
coEvery { mockRepository.complexOperation(any()) } returns Result.success("result")
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
assertEquals(DEFAULT_STATE, stateFlow.awaitItem())
|
||||
eventFlow.expectNoEvents()
|
||||
|
||||
viewModel.trySendAction(ExampleAction.ComplexAction)
|
||||
|
||||
assertEquals(DEFAULT_STATE.copy(isLoading = true), stateFlow.awaitItem())
|
||||
assertEquals(DEFAULT_STATE.copy(data = "result"), stateFlow.awaitItem())
|
||||
assertEquals(ExampleEvent.ShowToast("Success!"), eventFlow.awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test state restoration from saved state.
|
||||
* Note: Use initialState parameter, NOT SavedStateHandle directly.
|
||||
*/
|
||||
@Test
|
||||
fun `initial state from saved state should be preserved`() = runTest {
|
||||
// Build complete expected state - always assert full objects
|
||||
val savedState = ExampleState(
|
||||
isLoading = false,
|
||||
data = "restored data",
|
||||
errorMessage = null,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel(initialState = savedState)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(savedState, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method accepts domain state, NOT SavedStateHandle.
|
||||
* This hides Android framework details from test logic.
|
||||
*/
|
||||
private fun createViewModel(
|
||||
initialState: ExampleState? = null,
|
||||
): ExampleViewModel = ExampleViewModel(
|
||||
savedStateHandle = SavedStateHandle().apply { set("state", initialState) },
|
||||
repository = mockRepository,
|
||||
authDiskSource = mockAuthDiskSource,
|
||||
)
|
||||
}
|
||||
|
||||
private val DEFAULT_STATE = ExampleState(
|
||||
isLoading = false,
|
||||
data = null,
|
||||
errorMessage = null,
|
||||
)
|
||||
|
||||
// Example types (normally in separate files)
|
||||
data class ExampleState(
|
||||
val isLoading: Boolean = false,
|
||||
val data: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
sealed class ExampleAction {
|
||||
data object LoadData : ExampleAction()
|
||||
data object SubmitClick : ExampleAction()
|
||||
data object ComplexAction : ExampleAction()
|
||||
}
|
||||
|
||||
sealed class ExampleEvent {
|
||||
data object NavigateToNext : ExampleEvent()
|
||||
data class ShowToast(val message: String) : ExampleEvent()
|
||||
}
|
||||
@@ -1,698 +0,0 @@
|
||||
# Critical Gotchas and Anti-Patterns
|
||||
|
||||
Common mistakes and pitfalls when writing tests in the Bitwarden Android codebase.
|
||||
|
||||
## ❌ NEVER wrap assertCoroutineThrows in runTest
|
||||
|
||||
### The Problem
|
||||
|
||||
`runTest` catches exceptions and rethrows them, which breaks the `assertCoroutineThrows` assertion pattern.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `test exception`() = runTest {
|
||||
assertCoroutineThrows<Exception> {
|
||||
repository.throwingFunction()
|
||||
} // Won't work - exception is caught by runTest!
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `test exception`() {
|
||||
assertCoroutineThrows<Exception> {
|
||||
repository.throwingFunction()
|
||||
} // Works correctly
|
||||
}
|
||||
```
|
||||
|
||||
### Why This Happens
|
||||
|
||||
`runTest` provides a coroutine scope and catches exceptions to provide better error messages. However, `assertCoroutineThrows` needs to catch the exception itself to verify it was thrown. When wrapped in `runTest`, the exception is caught twice, breaking the assertion.
|
||||
|
||||
## ❌ ALWAYS unmock static functions
|
||||
|
||||
### The Problem
|
||||
|
||||
MockK's static mocking persists across tests. Forgetting to clean up causes mysterious failures in subsequent tests.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Before
|
||||
fun setup() {
|
||||
mockkStatic(::isBuildVersionAtLeast)
|
||||
every { isBuildVersionAtLeast(any()) } returns true
|
||||
}
|
||||
|
||||
// Forgot @After - subsequent tests will fail mysteriously!
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
@Before
|
||||
fun setup() {
|
||||
mockkStatic(::isBuildVersionAtLeast)
|
||||
every { isBuildVersionAtLeast(any()) } returns true
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkStatic(::isBuildVersionAtLeast) // CRITICAL
|
||||
}
|
||||
```
|
||||
|
||||
### Common Static Functions to Watch
|
||||
|
||||
```kotlin
|
||||
// Platform version checks
|
||||
mockkStatic(::isBuildVersionAtLeast)
|
||||
unmockkStatic(::isBuildVersionAtLeast)
|
||||
|
||||
// URI parsing
|
||||
mockkStatic(Uri::class)
|
||||
unmockkStatic(Uri::class)
|
||||
|
||||
// Static utility functions
|
||||
mockkStatic(MyUtilClass::class)
|
||||
unmockkStatic(MyUtilClass::class)
|
||||
```
|
||||
|
||||
### Debugging Tip
|
||||
|
||||
If tests pass individually but fail when run together, suspect static mocking cleanup issues.
|
||||
|
||||
## ❌ Don't confuse StateFlow and EventFlow testing
|
||||
|
||||
### StateFlow (replay = 1)
|
||||
|
||||
```kotlin
|
||||
// CORRECT - StateFlow always has current value
|
||||
viewModel.stateFlow.test {
|
||||
val initial = awaitItem() // Gets current state immediately
|
||||
viewModel.trySendAction(action)
|
||||
val updated = awaitItem() // Gets new state
|
||||
}
|
||||
```
|
||||
|
||||
### EventFlow (no replay)
|
||||
|
||||
```kotlin
|
||||
// CORRECT - EventFlow has no initial value
|
||||
viewModel.eventFlow.test {
|
||||
expectNoEvents() // MUST do this first
|
||||
viewModel.trySendAction(action)
|
||||
val event = awaitItem() // Gets emitted event
|
||||
}
|
||||
```
|
||||
|
||||
### Common Mistake
|
||||
|
||||
```kotlin
|
||||
// WRONG - Forgetting expectNoEvents() on EventFlow
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(action) // May cause flaky tests
|
||||
assertEquals(event, awaitItem())
|
||||
}
|
||||
```
|
||||
|
||||
## ❌ Don't mix real and test dispatchers
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
private val repository = ExampleRepositoryImpl(
|
||||
dispatcherManager = DispatcherManagerImpl(), // Real dispatcher!
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `test repository`() = runTest {
|
||||
// Test will have timing issues - real dispatcher != test dispatcher
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
private val repository = ExampleRepositoryImpl(
|
||||
dispatcherManager = FakeDispatcherManager(), // Test dispatcher
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `test repository`() = runTest {
|
||||
// Test runs deterministically
|
||||
}
|
||||
```
|
||||
|
||||
### Why This Matters
|
||||
|
||||
Real dispatchers use actual thread pools and delays. Test dispatchers (UnconfinedTestDispatcher) execute immediately and deterministically. Mixing them causes:
|
||||
- Non-deterministic test failures
|
||||
- Real delays in tests (slow test suite)
|
||||
- Race conditions
|
||||
|
||||
### Always Use
|
||||
|
||||
- `FakeDispatcherManager()` for repositories
|
||||
- `UnconfinedTestDispatcher()` when manually creating dispatchers
|
||||
- `runTest` for coroutine tests (provides TestDispatcher automatically)
|
||||
|
||||
## ❌ Don't forget to use runTest for coroutine tests
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `test coroutine`() {
|
||||
viewModel.stateFlow.test { /* ... */ } // Missing runTest!
|
||||
}
|
||||
```
|
||||
|
||||
This causes:
|
||||
- Test completes before coroutines finish
|
||||
- False positives (test passes but assertions never run)
|
||||
- Mysterious failures
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `test coroutine`() = runTest {
|
||||
viewModel.stateFlow.test { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### When runTest is Required
|
||||
|
||||
- Testing ViewModels (they use `viewModelScope`)
|
||||
- Testing Flows with Turbine `.test {}`
|
||||
- Testing repositories with suspend functions
|
||||
- Any test calling suspend functions
|
||||
|
||||
### Exception: assertCoroutineThrows
|
||||
|
||||
As noted above, `assertCoroutineThrows` should NOT be wrapped in `runTest`.
|
||||
|
||||
## ❌ Don't forget relaxed = true for complex mocks
|
||||
|
||||
### Without relaxed
|
||||
|
||||
```kotlin
|
||||
private val viewModel = mockk<ExampleViewModel>() // Must mock every method!
|
||||
|
||||
// Error: "no answer found for: stateFlow"
|
||||
```
|
||||
|
||||
### With relaxed
|
||||
|
||||
```kotlin
|
||||
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
|
||||
// Only mock what you care about
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
}
|
||||
```
|
||||
|
||||
### When to Use relaxed
|
||||
|
||||
- Mocking ViewModels in Compose tests
|
||||
- Mocking complex objects with many methods
|
||||
- When you only care about specific method calls
|
||||
|
||||
### When NOT to Use relaxed
|
||||
|
||||
- Mocking repository interfaces (be explicit about behavior)
|
||||
- When you want to verify NO unexpected calls
|
||||
- Testing error paths (want test to fail if unexpected method called)
|
||||
|
||||
## ❌ Don't assert individual fields when complete state is available
|
||||
|
||||
### The Problem
|
||||
|
||||
Asserting individual state fields can miss unintended side effects on other fields.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `action should update state`() = runTest {
|
||||
viewModel.trySendAction(SomeAction.DoThing)
|
||||
|
||||
val state = viewModel.stateFlow.value
|
||||
assertEquals(null, state.dialog) // Only checks one field!
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `action should update state`() = runTest {
|
||||
viewModel.trySendAction(SomeAction.DoThing)
|
||||
|
||||
val expected = SomeState(
|
||||
isLoading = false,
|
||||
data = "result",
|
||||
dialog = null,
|
||||
)
|
||||
assertEquals(expected, viewModel.stateFlow.value) // Checks all fields
|
||||
}
|
||||
```
|
||||
|
||||
### Why This Matters
|
||||
|
||||
- Catches unintended mutations to other state fields
|
||||
- Makes expected state explicit and readable
|
||||
- Prevents silent regressions when state structure changes
|
||||
|
||||
---
|
||||
|
||||
## ❌ Don't use Kotlin assert() for boolean checks
|
||||
|
||||
### The Problem
|
||||
|
||||
Kotlin's `assert()` doesn't follow JUnit conventions and provides poor failure messages.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `event should trigger callback`() {
|
||||
mutableEventFlow.tryEmit(SomeEvent.Navigate)
|
||||
|
||||
assert(onNavigateCalled) // Kotlin assert - bad failure messages
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `event should trigger callback`() {
|
||||
mutableEventFlow.tryEmit(SomeEvent.Navigate)
|
||||
|
||||
assertTrue(onNavigateCalled) // JUnit assertTrue - proper assertion
|
||||
}
|
||||
```
|
||||
|
||||
### Always Use JUnit Assertions
|
||||
|
||||
- `assertTrue()` / `assertFalse()` for booleans
|
||||
- `assertEquals()` for value comparisons
|
||||
- `assertNotNull()` / `assertNull()` for nullability
|
||||
- `assertThrows<T>()` for exceptions
|
||||
|
||||
---
|
||||
|
||||
## ❌ Don't pass SavedStateHandle to test factory methods
|
||||
|
||||
### The Problem
|
||||
|
||||
Exposing `SavedStateHandle` in test factory methods leaks Android framework details into test logic.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
private fun createViewModel(
|
||||
savedStateHandle: SavedStateHandle = SavedStateHandle(), // Framework type exposed
|
||||
): MyViewModel = MyViewModel(
|
||||
savedStateHandle = savedStateHandle,
|
||||
repository = mockRepository,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `initial state from saved state`() = runTest {
|
||||
val savedState = MyState(isLoading = true)
|
||||
val savedStateHandle = SavedStateHandle(mapOf("state" to savedState))
|
||||
|
||||
val viewModel = createViewModel(savedStateHandle = savedStateHandle)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
private fun createViewModel(
|
||||
initialState: MyState? = null, // Domain type only
|
||||
): MyViewModel = MyViewModel(
|
||||
savedStateHandle = SavedStateHandle().apply { set("state", initialState) },
|
||||
repository = mockRepository,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `initial state from saved state`() = runTest {
|
||||
val savedState = MyState(isLoading = true)
|
||||
|
||||
val viewModel = createViewModel(initialState = savedState)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Why This Matters
|
||||
|
||||
- Cleaner, more intuitive test code
|
||||
- Hides SavedStateHandle implementation details
|
||||
- Follows Bitwarden conventions
|
||||
|
||||
---
|
||||
|
||||
## ❌ Don't test SavedStateHandle persistence in unit tests
|
||||
|
||||
### The Problem
|
||||
|
||||
Testing whether state persists to SavedStateHandle is testing Android framework behavior, not your business logic.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `state should persist to SavedStateHandle`() = runTest {
|
||||
val savedStateHandle = SavedStateHandle()
|
||||
val viewModel = createViewModel(savedStateHandle = savedStateHandle)
|
||||
|
||||
viewModel.trySendAction(SomeAction)
|
||||
|
||||
val savedState = savedStateHandle.get<MyState>("state")
|
||||
assertEquals(expectedState, savedState) // Testing framework, not logic!
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
Focus on testing business logic and state transformations:
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `action should update state correctly`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(SomeAction)
|
||||
|
||||
assertEquals(expectedState, viewModel.stateFlow.value) // Test observable state
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❌ Don't use static mocking when DI pattern is available
|
||||
|
||||
### The Problem
|
||||
|
||||
Static mocking (`mockkStatic`) is harder to maintain and less testable than dependency injection.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
class ParserTest {
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockkStatic(UUID::class)
|
||||
every { UUID.randomUUID() } returns mockk {
|
||||
every { toString() } returns "fixed-uuid"
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkStatic(UUID::class)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
Extract an interface and inject it:
|
||||
|
||||
```kotlin
|
||||
// Production code
|
||||
interface UuidManager {
|
||||
fun generateUuid(): String
|
||||
}
|
||||
|
||||
class UuidManagerImpl : UuidManager {
|
||||
override fun generateUuid(): String = UUID.randomUUID().toString()
|
||||
}
|
||||
|
||||
class Parser(private val uuidManager: UuidManager) { ... }
|
||||
|
||||
// Test code
|
||||
class ParserTest {
|
||||
private val mockUuidManager = mockk<UuidManager>()
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
every { mockUuidManager.generateUuid() } returns "fixed-uuid"
|
||||
}
|
||||
|
||||
// No tearDown needed - no static mocking!
|
||||
}
|
||||
```
|
||||
|
||||
### When to Use This Pattern
|
||||
|
||||
- UUID generation
|
||||
- Timestamp/Clock operations
|
||||
- System property access
|
||||
- Any static function that needs deterministic testing
|
||||
|
||||
---
|
||||
|
||||
## ❌ Don't forget to test null stream returns from Android APIs
|
||||
|
||||
### The Problem
|
||||
|
||||
Android's `ContentResolver.openOutputStream()` and `openInputStream()` can return null, not just throw exceptions.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
class FileManagerTest {
|
||||
@Test
|
||||
fun `stringToUri with exception should return false`() = runTest {
|
||||
every { mockContentResolver.openOutputStream(any()) } throws IOException()
|
||||
|
||||
val result = fileManager.stringToUri(mockUri, "data")
|
||||
|
||||
assertFalse(result)
|
||||
}
|
||||
// Missing: test for null return!
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
class FileManagerTest {
|
||||
@Test
|
||||
fun `stringToUri with exception should return false`() = runTest {
|
||||
every { mockContentResolver.openOutputStream(any()) } throws IOException()
|
||||
|
||||
val result = fileManager.stringToUri(mockUri, "data")
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stringToUri with null stream should return false`() = runTest {
|
||||
every { mockContentResolver.openOutputStream(any()) } returns null
|
||||
|
||||
val result = fileManager.stringToUri(mockUri, "data")
|
||||
assertFalse(result) // CRITICAL: must handle null!
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Common Android APIs That Return Null
|
||||
|
||||
- `ContentResolver.openOutputStream()` / `openInputStream()`
|
||||
- `Context.getExternalFilesDir()`
|
||||
- `PackageManager.getApplicationInfo()` (can throw)
|
||||
|
||||
---
|
||||
|
||||
## Bitwarden Mocking Guidelines
|
||||
|
||||
**Mock at architectural boundaries:**
|
||||
- Repository → ViewModel (mock repository)
|
||||
- Service → Repository (mock service)
|
||||
- API → Service (use MockWebServer, not mocks)
|
||||
- DiskSource → Repository (mock disk source)
|
||||
|
||||
**Fake vs Mock Strategy (IMPORTANT):**
|
||||
- **Happy paths**: Use Fake implementations (`FakeAuthenticatorDiskSource`, `FakeVaultDiskSource`)
|
||||
- **Error paths**: Use MockK with isolated repository instances
|
||||
|
||||
```kotlin
|
||||
// Happy path - use Fake
|
||||
private val fakeDiskSource = FakeAuthenticatorDiskSource()
|
||||
|
||||
@Test
|
||||
fun `createItem should return Success`() = runTest {
|
||||
val result = repository.createItem(mockItem)
|
||||
assertEquals(CreateItemResult.Success, result)
|
||||
}
|
||||
|
||||
// Error path - use isolated Mock
|
||||
@Test
|
||||
fun `createItem with exception should return Error`() = runTest {
|
||||
val mockDiskSource = mockk<AuthenticatorDiskSource> {
|
||||
coEvery { saveItem(any()) } throws RuntimeException()
|
||||
}
|
||||
val repository = RepositoryImpl(diskSource = mockDiskSource)
|
||||
|
||||
val result = repository.createItem(mockItem)
|
||||
assertEquals(CreateItemResult.Error, result)
|
||||
}
|
||||
```
|
||||
|
||||
**Use Fakes for:**
|
||||
- `FakeDispatcherManager` - deterministic coroutines
|
||||
- `FakeConfigDiskSource` - in-memory config storage
|
||||
- `FakeSharedPreferences` - memory-backed preferences
|
||||
- `FakeAuthenticatorDiskSource` - in-memory authenticator storage
|
||||
|
||||
**Create real instances for:**
|
||||
- Data classes, value objects (User, Config, CipherView)
|
||||
- Test data builders (`createMockCipher(number = 1)`)
|
||||
|
||||
## ❌ Don't forget bufferedMutableSharedFlow with onSubscription for Fakes
|
||||
|
||||
### The Problem
|
||||
|
||||
Fake data sources using `MutableSharedFlow` won't emit cached state to new subscribers without explicit handling.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
class FakeDataSource : DataSource {
|
||||
private val mutableFlow = MutableSharedFlow<List<Item>>()
|
||||
private val storedItems = mutableListOf<Item>()
|
||||
|
||||
override fun getItems(): Flow<List<Item>> = mutableFlow
|
||||
|
||||
override suspend fun saveItem(item: Item) {
|
||||
storedItems.add(item)
|
||||
mutableFlow.emit(storedItems)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Initial collection gets nothing!
|
||||
repository.dataFlow.test {
|
||||
// Hangs or fails - no initial emission
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
class FakeDataSource : DataSource {
|
||||
private val mutableFlow = bufferedMutableSharedFlow<List<Item>>()
|
||||
private val storedItems = mutableListOf<Item>()
|
||||
|
||||
override fun getItems(): Flow<List<Item>> = mutableFlow
|
||||
.onSubscription { emit(storedItems.toList()) }
|
||||
|
||||
override suspend fun saveItem(item: Item) {
|
||||
storedItems.add(item)
|
||||
mutableFlow.emit(storedItems.toList())
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Initial collection receives current state
|
||||
repository.dataFlow.test {
|
||||
assertEquals(emptyList(), awaitItem()) // Works!
|
||||
}
|
||||
```
|
||||
|
||||
### Key Points
|
||||
|
||||
- Use `bufferedMutableSharedFlow()` from `core/data/repository/util/`
|
||||
- Add `.onSubscription { emit(currentState) }` for immediate state emission
|
||||
- This ensures new collectors receive the current cached state
|
||||
|
||||
---
|
||||
|
||||
## ✅ Use Result extension functions for assertions
|
||||
|
||||
### The Pattern
|
||||
|
||||
Use `asSuccess()` and `asFailure()` extensions from `com.bitwarden.core.data.util` for cleaner Result assertions.
|
||||
|
||||
### Success Path
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `getData should return success`() = runTest {
|
||||
val result = repository.getData()
|
||||
val expected = expectedData.asSuccess()
|
||||
|
||||
assertEquals(expected.getOrNull(), result.getOrNull())
|
||||
}
|
||||
```
|
||||
|
||||
### Failure Path
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `getData with error should return failure`() = runTest {
|
||||
val exception = IOException("Network error")
|
||||
coEvery { mockService.getData() } returns exception.asFailure()
|
||||
|
||||
val result = repository.getData()
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals(exception, result.exceptionOrNull())
|
||||
}
|
||||
```
|
||||
|
||||
### Avoid Redundant Assertions
|
||||
|
||||
```kotlin
|
||||
// WRONG - redundant success checks
|
||||
assertTrue(result.isSuccess)
|
||||
assertTrue(expected.isSuccess)
|
||||
assertArrayEquals(expected.getOrNull(), result.getOrNull())
|
||||
|
||||
// CORRECT - final assertion is sufficient
|
||||
assertArrayEquals(expected.getOrNull(), result.getOrNull())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary Checklist
|
||||
|
||||
Before submitting tests, verify:
|
||||
|
||||
**Core Patterns:**
|
||||
- [ ] No `assertCoroutineThrows` inside `runTest`
|
||||
- [ ] All static mocks have `unmockk` in `@After`
|
||||
- [ ] EventFlow tests start with `expectNoEvents()`
|
||||
- [ ] Using FakeDispatcherManager, not real dispatchers
|
||||
- [ ] All coroutine tests use `runTest`
|
||||
|
||||
**Assertion Patterns:**
|
||||
- [ ] Assert complete state objects, not individual fields
|
||||
- [ ] Use JUnit `assertTrue()`, not Kotlin `assert()`
|
||||
- [ ] Use `asSuccess()` for Result type assertions
|
||||
- [ ] Avoid redundant assertion patterns
|
||||
|
||||
**Test Design:**
|
||||
- [ ] Test factory methods accept domain types, not SavedStateHandle
|
||||
- [ ] Use Fakes for happy paths, Mocks for error paths
|
||||
- [ ] Prefer DI patterns over static mocking
|
||||
- [ ] Test null returns from Android APIs (streams, files)
|
||||
- [ ] Fakes use `bufferedMutableSharedFlow()` with `.onSubscription`
|
||||
|
||||
**General:**
|
||||
- [ ] Tests don't depend on execution order
|
||||
- [ ] Complex mocks use `relaxed = true`
|
||||
- [ ] Test data is created fresh for each test
|
||||
- [ ] Mocking behavior, not value objects
|
||||
- [ ] Testing observable behavior, not implementation
|
||||
|
||||
When tests fail mysteriously, check these gotchas first.
|
||||
@@ -1,274 +0,0 @@
|
||||
# Flow Testing with Turbine
|
||||
|
||||
Bitwarden Android uses Turbine for testing Kotlin Flows, including the critical distinction between StateFlow and EventFlow patterns.
|
||||
|
||||
## StateFlow vs EventFlow
|
||||
|
||||
### StateFlow (Replayed)
|
||||
|
||||
**Characteristics:**
|
||||
- `replay = 1` - Always emits current value to new collectors
|
||||
- First `awaitItem()` returns the current/initial state
|
||||
- Survives configuration changes
|
||||
- Used for UI state that needs to be immediately available
|
||||
|
||||
**Test Pattern:**
|
||||
```kotlin
|
||||
@Test
|
||||
fun `action should update state`() = runTest {
|
||||
val viewModel = MyViewModel(savedStateHandle, mockRepository)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
// First awaitItem() gets CURRENT state
|
||||
assertEquals(INITIAL_STATE, awaitItem())
|
||||
|
||||
// Trigger action
|
||||
viewModel.trySendAction(MyAction.LoadData)
|
||||
|
||||
// Next awaitItem() gets UPDATED state
|
||||
assertEquals(LOADING_STATE, awaitItem())
|
||||
assertEquals(SUCCESS_STATE, awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### EventFlow (No Replay)
|
||||
|
||||
**Characteristics:**
|
||||
- `replay = 0` - Only emits new events after subscription
|
||||
- No initial value emission
|
||||
- One-time events (navigation, toasts, dialogs)
|
||||
- Does not survive configuration changes
|
||||
|
||||
**Test Pattern:**
|
||||
```kotlin
|
||||
@Test
|
||||
fun `action should emit event`() = runTest {
|
||||
val viewModel = MyViewModel(savedStateHandle, mockRepository)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
// MUST call expectNoEvents() first - nothing emitted yet
|
||||
expectNoEvents()
|
||||
|
||||
// Trigger action
|
||||
viewModel.trySendAction(MyAction.Submit)
|
||||
|
||||
// Now expect the event
|
||||
assertEquals(MyEvent.NavigateToNext, awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Critical:** Always call `expectNoEvents()` before triggering actions on EventFlow. Forgetting this causes flaky tests.
|
||||
|
||||
## Testing State and Events Simultaneously
|
||||
|
||||
Use the `stateEventFlow()` helper from `BaseViewModelTest`:
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `complex action should update state and emit event`() = runTest {
|
||||
val viewModel = MyViewModel(savedStateHandle, mockRepository)
|
||||
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
// Initial state
|
||||
assertEquals(INITIAL_STATE, stateFlow.awaitItem())
|
||||
|
||||
// No events yet
|
||||
eventFlow.expectNoEvents()
|
||||
|
||||
// Trigger action
|
||||
viewModel.trySendAction(MyAction.ComplexAction)
|
||||
|
||||
// Verify state progression
|
||||
assertEquals(LOADING_STATE, stateFlow.awaitItem())
|
||||
assertEquals(SUCCESS_STATE, stateFlow.awaitItem())
|
||||
|
||||
// Verify event emission
|
||||
assertEquals(MyEvent.ShowToast, eventFlow.awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Repository Flow Testing
|
||||
|
||||
### Testing Database Flows
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `dataFlow should emit when database updates`() = runTest {
|
||||
val dataFlow = MutableStateFlow(initialData)
|
||||
every { mockDiskSource.dataFlow } returns dataFlow
|
||||
|
||||
repository.dataFlow.test {
|
||||
// Initial value
|
||||
assertEquals(initialData, awaitItem())
|
||||
|
||||
// Update disk source
|
||||
dataFlow.value = updatedData
|
||||
|
||||
// Verify emission
|
||||
assertEquals(updatedData, awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Transformed Flows
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `flow transformation should map correctly`() = runTest {
|
||||
val sourceFlow = MutableStateFlow(UserEntity(id = "1", name = "John"))
|
||||
every { mockDao.observeUser() } returns sourceFlow
|
||||
|
||||
// Repository transforms entity to domain model
|
||||
repository.userFlow.test {
|
||||
val expectedUser = User(id = "1", name = "John")
|
||||
assertEquals(expectedUser, awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Testing Initial State + Action
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `load data should update from idle to loading to success`() = runTest {
|
||||
coEvery { repository.getData() } returns "data".asSuccess()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.loadData()
|
||||
|
||||
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Loading), awaitItem())
|
||||
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Success), awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Testing Error States
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `load data with error should emit failure state`() = runTest {
|
||||
val error = Exception("Network error")
|
||||
coEvery { repository.getData() } returns error.asFailure()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.loadData()
|
||||
|
||||
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Loading), awaitItem())
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(loadingState = LoadingState.Error("Network error")),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Testing Event Sequences
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `submit should emit validation then navigation events`() = runTest {
|
||||
viewModel.eventFlow.test {
|
||||
expectNoEvents()
|
||||
|
||||
viewModel.trySendAction(MyAction.Submit)
|
||||
|
||||
assertEquals(MyEvent.ShowValidation, awaitItem())
|
||||
assertEquals(MyEvent.NavigateToNext, awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Testing Cancellation
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `cancelling collection should stop emissions`() = runTest {
|
||||
val flow = flow {
|
||||
repeat(100) {
|
||||
emit(it)
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
|
||||
flow.test {
|
||||
assertEquals(0, awaitItem())
|
||||
assertEquals(1, awaitItem())
|
||||
|
||||
// Cancel after 2 items
|
||||
cancel()
|
||||
|
||||
// No more items received
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### ❌ Forgetting expectNoEvents() on EventFlow
|
||||
|
||||
```kotlin
|
||||
// WRONG
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(action) // May fail - no initial expectNoEvents
|
||||
assertEquals(event, awaitItem())
|
||||
}
|
||||
|
||||
// CORRECT
|
||||
viewModel.eventFlow.test {
|
||||
expectNoEvents() // ALWAYS do this first
|
||||
viewModel.trySendAction(action)
|
||||
assertEquals(event, awaitItem())
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Not Using runTest
|
||||
|
||||
```kotlin
|
||||
// WRONG - Missing runTest
|
||||
@Test
|
||||
fun `test flow`() {
|
||||
flow.test { /* ... */ }
|
||||
}
|
||||
|
||||
// CORRECT
|
||||
@Test
|
||||
fun `test flow`() = runTest {
|
||||
flow.test { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Mixing StateFlow and EventFlow Patterns
|
||||
|
||||
```kotlin
|
||||
// WRONG - Treating StateFlow like EventFlow
|
||||
stateFlow.test {
|
||||
expectNoEvents() // Unnecessary - StateFlow always has value
|
||||
/* ... */
|
||||
}
|
||||
|
||||
// WRONG - Treating EventFlow like StateFlow
|
||||
eventFlow.test {
|
||||
val item = awaitItem() // Will hang - no initial value!
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
## Reference Implementations
|
||||
|
||||
**ViewModel with StateFlow and EventFlow:**
|
||||
`app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
|
||||
|
||||
**Repository Flow Testing:**
|
||||
`data/src/test/kotlin/com/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt`
|
||||
|
||||
**Complex Flow Transformations:**
|
||||
`data/src/test/kotlin/com/bitwarden/data/vault/repository/VaultRepositoryTest.kt`
|
||||
@@ -1,259 +0,0 @@
|
||||
# Test Base Classes Reference
|
||||
|
||||
Bitwarden Android provides specialized base classes that configure test environments and provide helper utilities.
|
||||
|
||||
## BaseViewModelTest
|
||||
|
||||
**Location:** `ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseViewModelTest.kt`
|
||||
|
||||
### Purpose
|
||||
Provides essential setup for testing ViewModels with proper coroutine dispatcher configuration and Flow testing helpers.
|
||||
|
||||
### Automatic Configuration
|
||||
- Registers `MainDispatcherExtension` for `UnconfinedTestDispatcher`
|
||||
- Ensures deterministic coroutine execution in tests
|
||||
- All coroutines complete immediately without real delays
|
||||
|
||||
### Key Feature: stateEventFlow() Helper
|
||||
|
||||
**Use Case:** When you need to test both StateFlow and EventFlow simultaneously.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `complex action should update state and emit event`() = runTest {
|
||||
val viewModel = ExampleViewModel(savedStateHandle, mockRepository)
|
||||
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
// Verify initial state
|
||||
assertEquals(INITIAL_STATE, stateFlow.awaitItem())
|
||||
|
||||
// No events yet
|
||||
eventFlow.expectNoEvents()
|
||||
|
||||
// Trigger action
|
||||
viewModel.trySendAction(ExampleAction.ComplexAction)
|
||||
|
||||
// Verify state updated
|
||||
assertEquals(LOADING_STATE, stateFlow.awaitItem())
|
||||
|
||||
// Verify event emitted
|
||||
assertEquals(ExampleEvent.ShowToast, eventFlow.awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Pattern
|
||||
|
||||
```kotlin
|
||||
class MyViewModelTest : BaseViewModelTest() {
|
||||
private val mockRepository: MyRepository = mockk()
|
||||
private val savedStateHandle = SavedStateHandle(
|
||||
mapOf(KEY_STATE to INITIAL_STATE)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `test action`() = runTest {
|
||||
val viewModel = MyViewModel(
|
||||
savedStateHandle = savedStateHandle,
|
||||
repository = mockRepository
|
||||
)
|
||||
|
||||
// Test with automatic dispatcher setup
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(INITIAL_STATE, awaitItem())
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## BitwardenComposeTest
|
||||
|
||||
**Location:** `app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt`
|
||||
|
||||
### Purpose
|
||||
Pre-configured test class for Compose UI tests with all Bitwarden managers and theme setup.
|
||||
|
||||
### Automatic Configuration
|
||||
- All Bitwarden managers pre-configured (FeatureFlags, AuthTab, Biometrics, etc.)
|
||||
- Wraps content in `BitwardenTheme` and `LocalManagerProvider`
|
||||
- Provides fixed `Clock` for deterministic time-based tests
|
||||
- Extends `BaseComposeTest` for Robolectric and dispatcher setup
|
||||
|
||||
### Key Features
|
||||
|
||||
**Pre-configured Managers:**
|
||||
- `FeatureFlagManager` - Controls feature flag behavior
|
||||
- `AuthTabManager` - Manages auth tab state
|
||||
- `BiometricsManager` - Handles biometric authentication
|
||||
- `ClipboardManager` - Clipboard operations
|
||||
- `NotificationManager` - Notification display
|
||||
|
||||
**Fixed Clock:**
|
||||
All tests use a fixed clock for deterministic time-based testing:
|
||||
```kotlin
|
||||
// Tests use consistent time: 2023-10-27T12:00:00Z
|
||||
val fixedClock: Clock
|
||||
```
|
||||
|
||||
### Usage Pattern
|
||||
|
||||
```kotlin
|
||||
class MyScreenTest : BitwardenComposeTest() {
|
||||
private var haveCalledNavigateBack = false
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<MyEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val viewModel = mockk<MyViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
setContent {
|
||||
MyScreen(
|
||||
onNavigateBack = { haveCalledNavigateBack = true },
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on back click should send action`() {
|
||||
composeTestRule.onNodeWithContentDescription("Back").performClick()
|
||||
verify { viewModel.trySendAction(MyAction.BackClick) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loading state should show progress`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(isLoading = true)
|
||||
composeTestRule.onNode(isProgressBar).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Important: bufferedMutableSharedFlow for Events
|
||||
|
||||
In Compose tests, use `bufferedMutableSharedFlow` instead of regular `MutableSharedFlow` (default replay is 0):
|
||||
|
||||
```kotlin
|
||||
// Correct for Compose tests
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<MyEvent>()
|
||||
|
||||
// This allows triggering events and having the UI react
|
||||
mutableEventFlow.tryEmit(MyEvent.NavigateBack)
|
||||
```
|
||||
|
||||
## BaseServiceTest
|
||||
|
||||
**Location:** `network/src/testFixtures/kotlin/com/bitwarden/network/base/BaseServiceTest.kt`
|
||||
|
||||
### Purpose
|
||||
Provides MockWebServer setup for testing API service implementations.
|
||||
|
||||
### Automatic Configuration
|
||||
- `server: MockWebServer` - Auto-started before each test, stopped after
|
||||
- `retrofit: Retrofit` - Pre-configured with:
|
||||
- JSON converter (kotlinx.serialization)
|
||||
- NetworkResultCallAdapter for Result<T> responses
|
||||
- Base URL pointing to MockWebServer
|
||||
- `json: Json` - kotlinx.serialization JSON instance
|
||||
|
||||
### Usage Pattern
|
||||
|
||||
```kotlin
|
||||
class MyServiceTest : BaseServiceTest() {
|
||||
private val api: MyApi = retrofit.create()
|
||||
private val service = MyServiceImpl(api)
|
||||
|
||||
@Test
|
||||
fun `getConfig should return success when API succeeds`() = runTest {
|
||||
// Enqueue mock response
|
||||
server.enqueue(MockResponse().setBody(EXPECTED_JSON))
|
||||
|
||||
// Call service
|
||||
val result = service.getConfig()
|
||||
|
||||
// Verify result
|
||||
assertEquals(EXPECTED_RESPONSE.asSuccess(), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConfig should return failure when API fails`() = runTest {
|
||||
// Enqueue error response
|
||||
server.enqueue(MockResponse().setResponseCode(500))
|
||||
|
||||
// Call service
|
||||
val result = service.getConfig()
|
||||
|
||||
// Verify failure
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MockWebServer Patterns
|
||||
|
||||
**Enqueue successful response:**
|
||||
```kotlin
|
||||
server.enqueue(MockResponse().setBody("""{"key": "value"}"""))
|
||||
```
|
||||
|
||||
**Enqueue error response:**
|
||||
```kotlin
|
||||
server.enqueue(MockResponse().setResponseCode(404))
|
||||
server.enqueue(MockResponse().setResponseCode(500))
|
||||
```
|
||||
|
||||
**Enqueue delayed response:**
|
||||
```kotlin
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setBody("""{"key": "value"}""")
|
||||
.setBodyDelay(1000, TimeUnit.MILLISECONDS)
|
||||
)
|
||||
```
|
||||
|
||||
**Verify request details:**
|
||||
```kotlin
|
||||
val request = server.takeRequest()
|
||||
assertEquals("/api/config", request.path)
|
||||
assertEquals("GET", request.method)
|
||||
assertEquals("Bearer token", request.getHeader("Authorization"))
|
||||
```
|
||||
|
||||
## BaseComposeTest
|
||||
|
||||
**Location:** `ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseComposeTest.kt`
|
||||
|
||||
### Purpose
|
||||
Base class for Compose tests that extends `BaseRobolectricTest` and provides `setTestContent()` helper.
|
||||
|
||||
### Features
|
||||
- Robolectric configuration for Compose
|
||||
- Proper dispatcher setup
|
||||
- `composeTestRule` for UI testing
|
||||
- `setTestContent()` helper wraps content in theme
|
||||
|
||||
### Usage
|
||||
Typically you'll extend `BitwardenComposeTest` which extends this class. Use `BaseComposeTest` directly only for tests that don't need Bitwarden-specific manager configuration.
|
||||
|
||||
## When to Use Each Base Class
|
||||
|
||||
| Test Type | Base Class | Use When |
|
||||
|-----------|------------|----------|
|
||||
| ViewModel tests | `BaseViewModelTest` | Testing ViewModel state and events |
|
||||
| Compose screen tests | `BitwardenComposeTest` | Testing Compose UI with Bitwarden components |
|
||||
| API service tests | `BaseServiceTest` | Testing network layer with MockWebServer |
|
||||
| Repository tests | None (manual setup) | Testing repository logic with mocked dependencies |
|
||||
| Utility/helper tests | None (manual setup) | Testing pure functions or utilities |
|
||||
|
||||
## Complete Examples
|
||||
|
||||
**ViewModel Test:**
|
||||
`../examples/viewmodel-test-example.md`
|
||||
|
||||
**Compose Screen Test:**
|
||||
`../examples/compose-screen-test-example.md`
|
||||
|
||||
**Repository Test:**
|
||||
`../examples/repository-test-example.md`
|
||||
@@ -12,7 +12,7 @@ runs:
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -22,7 +22,7 @@ runs:
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
@@ -44,5 +44,6 @@ runs:
|
||||
- name: Install Fastlane
|
||||
shell: bash
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
25
.github/label-pr.json
vendored
25
.github/label-pr.json
vendored
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"catch_all_label": "t:misc",
|
||||
"title_patterns": {
|
||||
"t:feature": ["feat", "feature", "tool"],
|
||||
"t:new-feature": ["feat", "feature"],
|
||||
"t:enhancement": ["enhancement", "enh", "impr"],
|
||||
"t:bug": ["fix", "bug", "bugfix"],
|
||||
"t:tech-debt": ["refactor", "chore", "cleanup", "revert", "debt", "test", "perf"],
|
||||
"t:docs": ["docs"],
|
||||
"t:ci": ["ci", "build", "chore(ci)"],
|
||||
"t:deps": ["deps"],
|
||||
"t:breaking-change": ["breaking", "breaking-change"],
|
||||
"t:misc": ["misc"],
|
||||
"t:llm": ["llm"]
|
||||
"t:misc": ["misc"]
|
||||
},
|
||||
"path_patterns": {
|
||||
"app:shared": [
|
||||
@@ -22,27 +23,17 @@
|
||||
],
|
||||
"app:password-manager": [
|
||||
"app/",
|
||||
"cxf/",
|
||||
"testharness/"
|
||||
"cxf/"
|
||||
],
|
||||
"app:authenticator": [
|
||||
"authenticator/"
|
||||
],
|
||||
"t:feature": [
|
||||
"app/src/main/assets/fido2_privileged_community.json",
|
||||
"app/src/main/assets/fido2_privileged_google.json",
|
||||
"testharness/"
|
||||
],
|
||||
"t:tech-debt": [
|
||||
"gradle.properties",
|
||||
"keystore/"
|
||||
],
|
||||
"t:ci": [
|
||||
".checkmarx/",
|
||||
".github/",
|
||||
"scripts/",
|
||||
"fastlane/",
|
||||
".gradle/",
|
||||
".claude/",
|
||||
"detekt-config.yml"
|
||||
],
|
||||
"t:docs": [
|
||||
@@ -51,8 +42,8 @@
|
||||
"t:deps": [
|
||||
"gradle/"
|
||||
],
|
||||
"t:llm": [
|
||||
".claude/"
|
||||
"t:misc": [
|
||||
"keystore/"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
34
.github/release.yml
vendored
34
.github/release.yml
vendored
@@ -1,34 +0,0 @@
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- ignore-for-release
|
||||
categories:
|
||||
- title: '✨ Community Highlight'
|
||||
labels:
|
||||
- community-pr
|
||||
- title: ':shipit: Feature Development'
|
||||
labels:
|
||||
- t:feature
|
||||
- t:feature-app
|
||||
- t:feature-tool
|
||||
- t:new-feature
|
||||
- t:enhancement
|
||||
- title: '❗ Breaking Changes'
|
||||
labels:
|
||||
- t:breaking-change
|
||||
- title: '🐛 Bug fixes'
|
||||
labels:
|
||||
- t:bug
|
||||
- title: '⚙️ Maintenance'
|
||||
labels:
|
||||
- t:tech-debt
|
||||
- t:ci
|
||||
- t:docs
|
||||
- t:misc
|
||||
- title: '📦 Dependency Updates'
|
||||
labels:
|
||||
- dependencies
|
||||
- t:deps
|
||||
- title: '🎨 Other'
|
||||
labels:
|
||||
- '*'
|
||||
15
.github/renovate.json
vendored
15
.github/renovate.json
vendored
@@ -3,7 +3,6 @@
|
||||
"extends": [
|
||||
"github>bitwarden/renovate-config"
|
||||
],
|
||||
"ignoreDeps": ["com.bitwarden:sdk-android"],
|
||||
"enabledManagers": [
|
||||
"github-actions",
|
||||
"gradle",
|
||||
@@ -20,6 +19,20 @@
|
||||
"patch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"groupName": "gradle minor",
|
||||
"matchUpdateTypes": [
|
||||
"minor",
|
||||
"patch"
|
||||
],
|
||||
"matchManagers": [
|
||||
"gradle"
|
||||
],
|
||||
"excludePackageNames": [
|
||||
"com.github.bumptech.glide:compose",
|
||||
"com.bitwarden:sdk-android"
|
||||
]
|
||||
},
|
||||
{
|
||||
"groupName": "kotlin",
|
||||
"description": "Kotlin and Compose dependencies that must be updated together to maintain compatibility.",
|
||||
|
||||
@@ -40,7 +40,7 @@ Single line of release notes text
|
||||
|
||||
```json
|
||||
...
|
||||
"customfield_9999": {
|
||||
"customfield_10335": {
|
||||
"type": "doc",
|
||||
"version": 1,
|
||||
"content": [
|
||||
@@ -62,7 +62,7 @@ Single line of release notes text
|
||||
|
||||
```json
|
||||
...
|
||||
"customfield_9999": {
|
||||
"customfield_10335": {
|
||||
"type": "doc",
|
||||
"version": 1,
|
||||
"content": [
|
||||
|
||||
@@ -5,8 +5,6 @@ import base64
|
||||
import json
|
||||
import requests
|
||||
|
||||
SCRIPT_NAME = "jira_release_notes.py"
|
||||
|
||||
def extract_text_from_content(content):
|
||||
if isinstance(content, list):
|
||||
texts = [extract_text_from_content(item) for item in content]
|
||||
@@ -25,42 +23,19 @@ def extract_text_from_content(content):
|
||||
|
||||
return ''
|
||||
|
||||
def log_customfields_with_content(fields):
|
||||
"""Log all customfield_* fields that have a 'content' key to help troubleshoot structure changes."""
|
||||
print(f"[{SCRIPT_NAME}] Available customfield_* fields with 'content':", file=sys.stderr)
|
||||
found = False
|
||||
for key, value in fields.items():
|
||||
if key.startswith('customfield_') and isinstance(value, dict) and 'content' in value:
|
||||
found = True
|
||||
print(f"[{SCRIPT_NAME}] {key}: {json.dumps(value, indent=2)}", file=sys.stderr)
|
||||
if not found:
|
||||
print(f"[{SCRIPT_NAME}] None found", file=sys.stderr)
|
||||
|
||||
def parse_release_notes(response_json):
|
||||
release_notes_field_name = 'customfield_10309'
|
||||
try:
|
||||
fields = response_json.get('fields')
|
||||
if not fields:
|
||||
print(f"[{SCRIPT_NAME}] 'fields' is empty or missing in response", file=sys.stderr)
|
||||
fields = response_json.get('fields', {})
|
||||
release_notes_field = fields.get('customfield_10335', {})
|
||||
|
||||
if not release_notes_field or not release_notes_field.get('content'):
|
||||
return ''
|
||||
|
||||
release_notes_field = fields.get(release_notes_field_name)
|
||||
if not release_notes_field:
|
||||
print(f"[{SCRIPT_NAME}] Release notes field is empty or missing. Field name: {release_notes_field_name}", file=sys.stderr)
|
||||
log_customfields_with_content(fields)
|
||||
return ''
|
||||
|
||||
content = release_notes_field.get('content', [])
|
||||
if not content:
|
||||
print(f"[{SCRIPT_NAME}] Release notes field was found but 'content' is empty or missing in {release_notes_field_name}", file=sys.stderr)
|
||||
log_customfields_with_content(fields)
|
||||
return ''
|
||||
|
||||
release_notes = extract_text_from_content(content)
|
||||
release_notes = extract_text_from_content(release_notes_field.get('content', []))
|
||||
return release_notes
|
||||
|
||||
except Exception as e:
|
||||
print(f"[{SCRIPT_NAME}] Error parsing release notes: {str(e)}", file=sys.stderr)
|
||||
print(f"Error parsing release notes: {str(e)}", file=sys.stderr)
|
||||
return ''
|
||||
|
||||
def main():
|
||||
@@ -85,7 +60,7 @@ def main():
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"[{SCRIPT_NAME}] Error fetching Jira issue ({jira_issue_id}). Status code: {response.status_code}. Msg: {response.text}", file=sys.stderr)
|
||||
print(f"Error fetching Jira issue: {response.status_code}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
release_notes = parse_release_notes(response.json())
|
||||
|
||||
61
.github/scripts/label-pr.py
vendored
61
.github/scripts/label-pr.py
vendored
@@ -4,22 +4,21 @@
|
||||
Label pull requests based on changed file paths and PR title patterns (conventional commit format).
|
||||
|
||||
Usage:
|
||||
python label-pr.py <pr-number> <pr-labels> [-a|--add|-r|--replace] [-d|--dry-run] [-c|--config CONFIG]
|
||||
python label-pr.py <pr-number> [-a|--add|-r|--replace] [-d|--dry-run] [-c|--config CONFIG]
|
||||
|
||||
Arguments:
|
||||
pr-number: The pull request number
|
||||
pr-labels: Current PR labels as JSON array string
|
||||
-a, --add: Add labels without removing existing ones (default)
|
||||
-r, --replace: Replace all existing labels
|
||||
-d, --dry-run: Run without actually applying labels
|
||||
-c, --config: Path to JSON config file (default: .github/label-pr.json)
|
||||
|
||||
Examples:
|
||||
python label-pr.py 1234 '[]'
|
||||
python label-pr.py 1234 '[{"name":"label1"}]' -a
|
||||
python label-pr.py 1234 '[{"name":"label1"}]' --replace
|
||||
python label-pr.py 1234 '[{"name":"label1"}]' -r -d
|
||||
python label-pr.py 1234 '[]' --config custom-config.json
|
||||
python label-pr.py 1234
|
||||
python label-pr.py 1234 -a
|
||||
python label-pr.py 1234 --replace
|
||||
python label-pr.py 1234 -r -d
|
||||
python label-pr.py 1234 --config custom-config.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -43,6 +42,9 @@ def load_config_json(config_file: str) -> dict:
|
||||
print(f"✅ Loaded config from: {config_file}")
|
||||
|
||||
valid_config = True
|
||||
if not config.get("catch_all_label"):
|
||||
print("❌ Missing 'catch_all_label' in config file")
|
||||
valid_config = False
|
||||
if not config.get("title_patterns"):
|
||||
print("❌ Missing 'title_patterns' in config file")
|
||||
valid_config = False
|
||||
@@ -129,7 +131,7 @@ def label_filepaths(changed_files: list[str], path_patterns: dict) -> list[str]:
|
||||
labels_to_apply.remove("app:shared")
|
||||
|
||||
if not labels_to_apply:
|
||||
print("::notice::No matching file paths found.")
|
||||
print("::warning::No matching file paths found, no labels applied.")
|
||||
|
||||
return list(labels_to_apply)
|
||||
|
||||
@@ -149,31 +151,10 @@ def label_title(pr_title: str, title_patterns: dict) -> list[str]:
|
||||
break
|
||||
|
||||
if not labels_to_apply:
|
||||
print("::notice::No matching title patterns found.")
|
||||
print("::warning::No matching title patterns found, no labels applied.")
|
||||
|
||||
return list(labels_to_apply)
|
||||
|
||||
def parse_pr_labels(pr_labels_str: str) -> list[str]:
|
||||
"""Parse PR labels from JSON array string."""
|
||||
try:
|
||||
labels = json.loads(pr_labels_str)
|
||||
if not isinstance(labels, list):
|
||||
print("::warning::Failed to parse PR labels: not a list")
|
||||
return []
|
||||
return [item.get("name") for item in labels if item.get("name")]
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
print(f"::error::Error parsing PR labels: {e}")
|
||||
return []
|
||||
|
||||
def get_preserved_labels(pr_labels_str: str) -> list[str]:
|
||||
"""Get existing PR labels that should be preserved (exclude app: and t: labels)."""
|
||||
existing_labels = parse_pr_labels(pr_labels_str)
|
||||
print(f"🔍 Parsed PR labels: {existing_labels}")
|
||||
preserved_labels = [label for label in existing_labels if not (label.startswith("app:") or label.startswith("t:"))]
|
||||
if preserved_labels:
|
||||
print(f"🔍 Preserving existing labels: {', '.join(preserved_labels)}")
|
||||
return preserved_labels
|
||||
|
||||
def parse_args():
|
||||
"""Parse command line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
@@ -184,11 +165,6 @@ def parse_args():
|
||||
help="The pull request number"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"pr_labels",
|
||||
help="Current PR labels (JSON array)"
|
||||
)
|
||||
|
||||
mode_group = parser.add_mutually_exclusive_group()
|
||||
mode_group.add_argument(
|
||||
"-a", "--add",
|
||||
@@ -218,6 +194,7 @@ def parse_args():
|
||||
def main():
|
||||
args = parse_args()
|
||||
config = load_config_json(args.config)
|
||||
CATCH_ALL_LABEL = config["catch_all_label"]
|
||||
LABEL_TITLE_PATTERNS = config["title_patterns"]
|
||||
LABEL_PATH_PATTERNS = config["path_patterns"]
|
||||
|
||||
@@ -239,23 +216,21 @@ def main():
|
||||
title_labels = label_title(pr_title, LABEL_TITLE_PATTERNS)
|
||||
all_labels = set(filepath_labels + title_labels)
|
||||
|
||||
if not any(label.startswith("t:") for label in all_labels):
|
||||
all_labels.add(CATCH_ALL_LABEL)
|
||||
|
||||
if all_labels:
|
||||
print("--------------------------------")
|
||||
labels_str = ', '.join(sorted(all_labels))
|
||||
if mode == "add":
|
||||
print(f"::notice::🏷️ Adding labels: {labels_str}")
|
||||
print(f"🏷️ Adding labels: {labels_str}")
|
||||
if not args.dry_run:
|
||||
gh_add_labels(pr_number, list(all_labels))
|
||||
else:
|
||||
preserved_labels = get_preserved_labels(args.pr_labels)
|
||||
if preserved_labels:
|
||||
all_labels.update(preserved_labels)
|
||||
labels_str = ', '.join(sorted(all_labels))
|
||||
print(f"::notice::🏷️ Replacing labels with: {labels_str}")
|
||||
print(f"🏷️ Replacing labels with: {labels_str}")
|
||||
if not args.dry_run:
|
||||
gh_replace_labels(pr_number, list(all_labels))
|
||||
else:
|
||||
print("::warning::No matching patterns found, no labels applied.")
|
||||
print("ℹ️ No matching patterns found, no labels applied.")
|
||||
|
||||
print("✅ Done")
|
||||
|
||||
|
||||
4
.github/workflows/_version.yml
vendored
4
.github/workflows/_version.yml
vendored
@@ -79,7 +79,7 @@ jobs:
|
||||
|
||||
- name: Check out repository
|
||||
if: ${{ !inputs.skip_checkout || false }}
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload version info artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: version-info
|
||||
path: version_info.json
|
||||
|
||||
54
.github/workflows/build-authenticator.yml
vendored
54
.github/workflows/build-authenticator.yml
vendored
@@ -21,19 +21,17 @@ on:
|
||||
distribute-to-firebase:
|
||||
description: "Optional. Distribute artifacts to Firebase."
|
||||
required: false
|
||||
default: true
|
||||
default: false
|
||||
type: boolean
|
||||
publish-to-play-store:
|
||||
description: "Optional. Deploy bundle artifact to Google Play Store"
|
||||
required: false
|
||||
default: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
JAVA_VERSION: 21
|
||||
DISTRIBUTE_TO_FIREBASE: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
PUBLISH_TO_PLAY_STORE: ${{ inputs.publish-to-play-store || github.event_name == 'push' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -61,7 +59,7 @@ jobs:
|
||||
inputs: "${{ toJson(inputs) }}"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -69,7 +67,7 @@ jobs:
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -79,7 +77,7 @@ jobs:
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
@@ -100,6 +98,7 @@ jobs:
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
@@ -124,7 +123,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -135,6 +134,7 @@ jobs:
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
--name com.bitwarden.authenticator.dev-google-services.json --file ${{ github.workspace }}/authenticator/src/debug/google-services.json --output none
|
||||
|
||||
- name: Download Firebase credentials
|
||||
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
|
||||
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
env:
|
||||
ACCOUNT_NAME: bitwardenci
|
||||
CONTAINER_NAME: mobile
|
||||
@@ -184,7 +184,7 @@ jobs:
|
||||
--name authenticator_play_firebase-creds.json --file ${{ github.workspace }}/secrets/authenticator_play_firebase-creds.json --output none
|
||||
|
||||
- name: Download Play Store credentials
|
||||
if: ${{ env.PUBLISH_TO_PLAY_STORE }}
|
||||
if: ${{ inputs.publish-to-play-store }}
|
||||
env:
|
||||
ACCOUNT_NAME: bitwardenci
|
||||
CONTAINER_NAME: mobile
|
||||
@@ -198,7 +198,7 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Verify Play Store credentials
|
||||
if: ${{ env.PUBLISH_TO_PLAY_STORE }}
|
||||
if: ${{ inputs.publish-to-play-store }}
|
||||
run: |
|
||||
bundle exec fastlane run validate_play_store_json_key \
|
||||
json_key:"${{ github.workspace }}/secrets/authenticator_play_store-creds.json"
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -217,7 +217,7 @@ jobs:
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
@@ -283,17 +283,17 @@ jobs:
|
||||
keyAlias:"bitwardenauthenticator" \
|
||||
keyPassword:"$KEY_PASSWORD"
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.aab
|
||||
- name: Upload release Play Store .aab artifact
|
||||
if: ${{ matrix.variant == 'aab' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: com.bitwarden.authenticator.aab
|
||||
path: authenticator/build/outputs/bundle/release/com.bitwarden.authenticator.aab
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.apk
|
||||
- name: Upload release .apk artifact
|
||||
if: ${{ matrix.variant == 'apk' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: com.bitwarden.authenticator.apk
|
||||
path: authenticator/build/outputs/apk/release/com.bitwarden.authenticator.apk
|
||||
@@ -311,36 +311,38 @@ jobs:
|
||||
sha256sum "authenticator/build/outputs/apk/release/com.bitwarden.authenticator.apk" \
|
||||
> ./authenticator-android-apk-sha256.txt
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
|
||||
- name: Upload .apk SHA file for release
|
||||
if: ${{ matrix.variant == 'apk' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: authenticator-android-apk-sha256.txt
|
||||
path: ./authenticator-android-apk-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
|
||||
- name: Upload .aab SHA file for release
|
||||
if: ${{ matrix.variant == 'aab' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: authenticator-android-aab-sha256.txt
|
||||
path: ./authenticator-android-aab-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Install Firebase app distribution plugin
|
||||
if: ${{ matrix.variant == 'aab' && env.DISTRIBUTE_TO_FIREBASE }}
|
||||
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
run: bundle exec fastlane add_plugin firebase_app_distribution
|
||||
|
||||
- name: Distribute to Firebase - prod.aab
|
||||
if: ${{ matrix.variant == 'aab' && env.DISTRIBUTE_TO_FIREBASE }}
|
||||
- name: Publish release bundle to Firebase
|
||||
if: ${{ matrix.variant == 'aab' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
|
||||
env:
|
||||
FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/authenticator_play_firebase-creds.json
|
||||
run: |
|
||||
bundle exec fastlane distributeAuthenticatorReleaseBundleToFirebase \
|
||||
serviceCredentialsFile:"$FIREBASE_CREDS_PATH"
|
||||
|
||||
- name: Publish to Play Store - prod.aab
|
||||
if: ${{ matrix.variant == 'aab' && env.PUBLISH_TO_PLAY_STORE }}
|
||||
# Only publish bundles to Play Store when `publish-to-play-store` is true while building
|
||||
# bundles
|
||||
- name: Publish release bundle to Google Play Store
|
||||
if: ${{ inputs.publish-to-play-store && matrix.variant == 'aab' }}
|
||||
env:
|
||||
PLAY_STORE_CREDS_FILE: ${{ github.workspace }}/secrets/authenticator_play_store-creds.json
|
||||
run: |
|
||||
|
||||
134
.github/workflows/build-testharness.yml
vendored
134
.github/workflows/build-testharness.yml
vendored
@@ -1,134 +0,0 @@
|
||||
name: Build Test Harness
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- testharness/**
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version-name:
|
||||
description: "Optional. Version string to use, in X.Y.Z format. Overrides default in the project."
|
||||
required: false
|
||||
type: string
|
||||
version-code:
|
||||
description: "Optional. Build number to use. Overrides default of GitHub run number."
|
||||
required: false
|
||||
type: number
|
||||
patch_version:
|
||||
description: "Order 999 - Overrides Patch version"
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
JAVA_VERSION: 21
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: Calculate Version Name and Number
|
||||
uses: bitwarden/android/.github/workflows/_version.yml@main
|
||||
with:
|
||||
app_codename: "bwpm"
|
||||
base_version_number: 0
|
||||
version_name: ${{ inputs.version-name }}
|
||||
version_number: ${{ inputs.version-code }}
|
||||
patch_version: ${{ inputs.patch_version && '999' || '' }}
|
||||
|
||||
build:
|
||||
name: Build Test Harness
|
||||
runs-on: ubuntu-24.04
|
||||
needs: version
|
||||
|
||||
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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Increment version
|
||||
env:
|
||||
DEFAULT_VERSION_CODE: ${{ github.run_number }}
|
||||
INPUT_VERSION_CODE: "${{ needs.version.outputs.version_number }}"
|
||||
INPUT_VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: |
|
||||
VERSION_CODE="${INPUT_VERSION_CODE:-$DEFAULT_VERSION_CODE}"
|
||||
VERSION_NAME_INPUT="${INPUT_VERSION_NAME:-}"
|
||||
bundle exec fastlane setBuildVersionInfo \
|
||||
versionCode:"$VERSION_CODE" \
|
||||
versionName:"$VERSION_NAME_INPUT"
|
||||
|
||||
regex='appVersionName = "(.+)"'
|
||||
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
|
||||
VERSION_NAME="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Build Test Harness Debug APK
|
||||
run: ./gradlew :testharness:assembleDebug
|
||||
|
||||
- name: Upload Test Harness APK
|
||||
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
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create checksum for Test Harness APK
|
||||
run: |
|
||||
sha256sum "testharness/build/outputs/apk/debug/com.bitwarden.testharness.dev.apk" \
|
||||
> ./com.bitwarden.testharness.dev.apk-sha256.txt
|
||||
|
||||
- name: Upload Test Harness SHA file
|
||||
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
|
||||
if-no-files-found: error
|
||||
107
.github/workflows/build.yml
vendored
107
.github/workflows/build.yml
vendored
@@ -33,8 +33,6 @@ env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
JAVA_VERSION: 21
|
||||
GITHUB_ACTION_RUN_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
DISTRIBUTE_TO_FIREBASE: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
PUBLISH_TO_PLAY_STORE: ${{ inputs.publish-to-play-store || github.event_name == 'push' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -63,7 +61,7 @@ jobs:
|
||||
inputs: "${{ toJson(inputs) }}"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -71,7 +69,7 @@ jobs:
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -81,7 +79,7 @@ jobs:
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
@@ -102,6 +100,7 @@ jobs:
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
@@ -112,7 +111,7 @@ jobs:
|
||||
run: bundle exec fastlane assembleDebugApks
|
||||
|
||||
- name: Upload test reports on failure
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
if: failure()
|
||||
with:
|
||||
name: test-reports
|
||||
@@ -133,7 +132,7 @@ jobs:
|
||||
artifact: ["apk", "aab"]
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -144,6 +143,7 @@ jobs:
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
--name google-services.json --file ${{ github.workspace }}/app/src/standardBeta/google-services.json --output none
|
||||
|
||||
- name: Download Firebase credentials
|
||||
if: ${{ matrix.variant == 'prod' && env.DISTRIBUTE_TO_FIREBASE }}
|
||||
if: ${{ matrix.variant == 'prod' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
|
||||
env:
|
||||
ACCOUNT_NAME: bitwardenci
|
||||
CONTAINER_NAME: mobile
|
||||
@@ -203,7 +203,7 @@ jobs:
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -213,7 +213,7 @@ jobs:
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
@@ -297,42 +297,42 @@ jobs:
|
||||
run: |
|
||||
bundle exec fastlane assembleDebugApks
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.aab
|
||||
- name: Upload release Play Store .aab artifact
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.aab
|
||||
path: app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden.aab
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.aab
|
||||
- name: Upload beta Play Store .aab artifact
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.aab
|
||||
path: app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden.beta.aab
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.apk
|
||||
- name: Upload release .apk artifact
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.apk
|
||||
path: app/build/outputs/apk/standard/release/com.x8bit.bitwarden.apk
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.apk
|
||||
- name: Upload beta .apk artifact
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.apk
|
||||
path: app/build/outputs/apk/standard/beta/com.x8bit.bitwarden.beta.apk
|
||||
if-no-files-found: error
|
||||
|
||||
# When building variants other than 'prod'
|
||||
- name: Upload to GitHub Artifacts - dev.apk
|
||||
- name: Upload debug .apk artifact
|
||||
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk
|
||||
path: app/build/outputs/apk/standard/debug/com.x8bit.bitwarden.dev.apk
|
||||
@@ -368,52 +368,52 @@ jobs:
|
||||
sha256sum "app/build/outputs/apk/standard/debug/com.x8bit.bitwarden.dev.apk" \
|
||||
> ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
|
||||
- name: Upload .apk SHA file for release
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.apk-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.apk-sha256.txt
|
||||
- name: Upload .apk SHA file for beta
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.beta.apk-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
|
||||
- name: Upload .aab SHA file for release
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.aab-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.aab-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.aab-sha256.txt
|
||||
- name: Upload .aab SHA file for beta
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.aab-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.beta.aab-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to GitHub Artifacts - debug.apk-sha256.txt
|
||||
- name: Upload .apk SHA file for debug
|
||||
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Install Firebase app distribution plugin
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && env.DISTRIBUTE_TO_FIREBASE }}
|
||||
if: ${{ matrix.variant == 'prod' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
|
||||
run: bundle exec fastlane add_plugin firebase_app_distribution
|
||||
|
||||
- name: Distribute to Firebase - prod.apk
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && env.DISTRIBUTE_TO_FIREBASE }}
|
||||
- name: Publish release artifacts to Firebase
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
|
||||
env:
|
||||
APP_PLAY_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json
|
||||
run: |
|
||||
@@ -421,8 +421,8 @@ jobs:
|
||||
actionUrl:$GITHUB_ACTION_RUN_URL \
|
||||
service_credentials_file:$APP_PLAY_FIREBASE_CREDS_PATH
|
||||
|
||||
- name: Distribute to Firebase - beta.apk
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && env.DISTRIBUTE_TO_FIREBASE }}
|
||||
- name: Publish beta artifacts to Firebase
|
||||
if: ${{ (matrix.variant == 'prod' && matrix.artifact == 'apk') && (inputs.distribute-to-firebase || github.event_name == 'push') }}
|
||||
env:
|
||||
APP_PLAY_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json
|
||||
run: |
|
||||
@@ -431,12 +431,12 @@ jobs:
|
||||
service_credentials_file:$APP_PLAY_FIREBASE_CREDS_PATH
|
||||
|
||||
- name: Verify Play Store credentials
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && env.PUBLISH_TO_PLAY_STORE }}
|
||||
if: ${{ matrix.variant == 'prod' && inputs.publish-to-play-store }}
|
||||
run: |
|
||||
bundle exec fastlane run validate_play_store_json_key
|
||||
|
||||
- name: Publish to Play Store - prod.aab
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && env.PUBLISH_TO_PLAY_STORE }}
|
||||
- name: Publish Play Store bundle
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && (inputs.publish-to-play-store || github.event_name == 'push') }}
|
||||
run: |
|
||||
bundle exec fastlane publishProdToPlayStore
|
||||
bundle exec fastlane publishBetaToPlayStore
|
||||
@@ -451,7 +451,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -462,6 +462,7 @@ jobs:
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
@@ -490,7 +491,7 @@ jobs:
|
||||
--name app_beta_fdroid-keystore.jks --file ${{ github.workspace }}/keystores/app_beta_fdroid-keystore.jks --output none
|
||||
|
||||
- name: Download Firebase credentials
|
||||
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
|
||||
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
env:
|
||||
ACCOUNT_NAME: bitwardenci
|
||||
CONTAINER_NAME: mobile
|
||||
@@ -507,7 +508,7 @@ jobs:
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -517,7 +518,7 @@ jobs:
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
@@ -577,8 +578,8 @@ jobs:
|
||||
keyAlias:bitwarden-beta \
|
||||
keyPassword:$FDROID_BETA_KEY_PASSWORD
|
||||
|
||||
- name: Upload to GitHub Artifacts - fdroid.apk
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
- name: Upload F-Droid .apk artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden-fdroid.apk
|
||||
path: app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid.apk
|
||||
@@ -589,15 +590,15 @@ jobs:
|
||||
sha256sum "app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid.apk" \
|
||||
> ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
|
||||
|
||||
- name: Upload to GitHub Artifacts - fdroid.apk-sha256.txt
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
- name: Upload F-Droid SHA file
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
- name: Upload F-Droid Beta .apk artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta-fdroid.apk
|
||||
path: app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden.beta-fdroid.apk
|
||||
@@ -608,19 +609,19 @@ jobs:
|
||||
sha256sum "app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden.beta-fdroid.apk" \
|
||||
> ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.fdroid.apk-sha256.txt
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
- name: Upload F-Droid Beta SHA file
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Install Firebase app distribution plugin
|
||||
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
|
||||
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
run: bundle exec fastlane add_plugin firebase_app_distribution
|
||||
|
||||
- name: Distribute to Firebase - fdroid.apk
|
||||
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
|
||||
- name: Publish release F-Droid artifacts to Firebase
|
||||
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
env:
|
||||
APP_FDROID_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_fdroid_firebase-creds.json
|
||||
run: |
|
||||
|
||||
@@ -2,8 +2,8 @@ name: Cron / Sync Google Privileged Browsers List
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run weekly on Sunday at 00:00 UTC
|
||||
- cron: '0 0 * * 0'
|
||||
# Run weekly on Monday at 00:00 UTC
|
||||
- cron: "0 0 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: true
|
||||
|
||||
@@ -96,4 +96,4 @@ jobs:
|
||||
--base main \
|
||||
--head "$BRANCH_NAME" \
|
||||
--label "automated-pr" \
|
||||
--label "t:deps"
|
||||
--label "t:ci"
|
||||
|
||||
13
.github/workflows/crowdin-pull.yml
vendored
13
.github/workflows/crowdin-pull.yml
vendored
@@ -4,21 +4,19 @@ run-name: Crowdin Pull - ${{ github.event_name == 'workflow_dispatch' && 'Manual
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Run weekly on Sunday at 00:00 UTC
|
||||
- cron: '0 0 * * 0'
|
||||
|
||||
permissions: {}
|
||||
- cron: "0 0 * * 5"
|
||||
|
||||
jobs:
|
||||
crowdin-sync:
|
||||
name: Crowdin Pull - ${{ github.event_name }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -52,8 +50,6 @@ jobs:
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||
permission-contents: write # for creating and pushing a new branch
|
||||
permission-pull-requests: write # for creating pull request
|
||||
|
||||
- name: Download translations
|
||||
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2.11.0
|
||||
@@ -73,6 +69,5 @@ jobs:
|
||||
create_pull_request: true
|
||||
pull_request_title: "Crowdin Pull"
|
||||
pull_request_body: ":inbox_tray: New translations received!"
|
||||
pull_request_labels: "automated-pr, t:misc"
|
||||
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
|
||||
gpg_passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
|
||||
|
||||
2
.github/workflows/crowdin-push.yml
vendored
2
.github/workflows/crowdin-push.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
16
.github/workflows/github-release.yml
vendored
16
.github/workflows/github-release.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
@@ -183,15 +183,11 @@ jobs:
|
||||
_JIRA_API_EMAIL: ${{ steps.get-kv-secrets.outputs.JIRA-API-EMAIL }}
|
||||
_JIRA_API_TOKEN: ${{ steps.get-kv-secrets.outputs.JIRA-API-TOKEN }}
|
||||
run: |
|
||||
echo "Getting product release notes..."
|
||||
# capture output and exit code so this step continues even if we can't retrieve release notes.
|
||||
script_exit_code=0
|
||||
product_release_notes=$(python .github/scripts/jira-get-release-notes/jira_release_notes.py "$_RELEASE_TICKET_ID" "$_JIRA_API_EMAIL" "$_JIRA_API_TOKEN") || script_exit_code=$?
|
||||
echo "--------------------------------"
|
||||
echo "Getting product release notes"
|
||||
product_release_notes=$(python3 .github/scripts/jira-get-release-notes/jira_release_notes.py "$_RELEASE_TICKET_ID" "$_JIRA_API_EMAIL" "$_JIRA_API_TOKEN")
|
||||
|
||||
if [[ $script_exit_code -ne 0 || -z "$product_release_notes" ]]; then
|
||||
echo "Script Output: $product_release_notes"
|
||||
echo "::warning::Failed to fetch release notes from Jira. Check script logs for more details."
|
||||
if [[ -z "$product_release_notes" || $product_release_notes == "Error checking"* ]]; then
|
||||
echo "::warning::Failed to fetch release notes from Jira. Output: $product_release_notes"
|
||||
product_release_notes="<insert product release notes here>"
|
||||
else
|
||||
echo "✅ Product release notes:"
|
||||
@@ -289,5 +285,5 @@ jobs:
|
||||
echo " * :ocean: Previous tag set in the description \"Full Changelog\" link: \`$_LAST_RELEASE_TAG\`"
|
||||
echo " * :white_check_mark: Description has automated release notes and they match the commits in the release branch"
|
||||
echo "> [!NOTE]"
|
||||
echo "> Commits directly pushed to branches without a Pull Request won't appear in the automated release notes."
|
||||
echo "> Commits directly pushed to branches without a Pull Request won't appear in the automated release notes."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -18,7 +18,6 @@ jobs:
|
||||
workflow_name: "publish-github-release-bwa.yml"
|
||||
credentials_filename: "authenticator_play_store-creds.json"
|
||||
project_type: android
|
||||
make_latest: false
|
||||
check_release_command: >
|
||||
bundle exec fastlane getLatestPlayStoreVersion package_name:com.bitwarden.authenticator track:production
|
||||
secrets: inherit
|
||||
|
||||
@@ -19,7 +19,6 @@ jobs:
|
||||
workflow_name: "publish-github-release-bwpm.yml"
|
||||
credentials_filename: "play_creds.json"
|
||||
project_type: android
|
||||
make_latest: true
|
||||
check_release_command: >
|
||||
bundle exec fastlane getLatestPlayStoreVersion package_name:com.x8bit.bitwarden track:production
|
||||
secrets: inherit
|
||||
|
||||
3
.github/workflows/publish-store.yml
vendored
3
.github/workflows/publish-store.yml
vendored
@@ -73,7 +73,7 @@ jobs:
|
||||
inputs: "${{ toJson(inputs) }}"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Configure Ruby
|
||||
@@ -83,6 +83,7 @@ jobs:
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
|
||||
2
.github/workflows/release-branch.yml
vendored
2
.github/workflows/release-branch.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
|
||||
2
.github/workflows/review-code.yml
vendored
2
.github/workflows/review-code.yml
vendored
@@ -2,7 +2,7 @@ name: Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
permissions: {}
|
||||
|
||||
|
||||
26
.github/workflows/sdlc-label-pr.yml
vendored
26
.github/workflows/sdlc-label-pr.yml
vendored
@@ -1,8 +1,6 @@
|
||||
name: SDLC / Label PR
|
||||
run-name: Label PR ${{ github.event.pull_request.number || inputs.pr-number }}${{ github.event_name == 'workflow_dispatch' && format(' / mode "{0}" dry-run "{1}"', inputs.mode, inputs.dry-run) || '' }}
|
||||
name: SDLC / Label PR by Files
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr-number:
|
||||
@@ -21,9 +19,6 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
env:
|
||||
_PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr-number }}
|
||||
|
||||
jobs:
|
||||
label-pr:
|
||||
name: Label PR by Changed Files
|
||||
@@ -34,7 +29,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -42,6 +37,7 @@ jobs:
|
||||
id: label-mode
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
_PR_NUMBER: ${{ inputs.pr-number }}
|
||||
_PR_USER: ${{ github.event.pull_request.user.login }}
|
||||
_IS_FORK: ${{ github.event.pull_request.head.repo.fork }}
|
||||
run: |
|
||||
@@ -63,7 +59,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$_PR_USER" == app/* || "$_PR_USER" == *\[bot\] ]]; then
|
||||
if [ "$_PR_USER" = "renovate[bot]" ] || [ "$_PR_USER" = "bw-ghapp[bot]" ]; then
|
||||
echo "➡️ Bot PR ($_PR_USER). Label mode: --add"
|
||||
echo "label_mode=--add" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
@@ -75,16 +71,10 @@ jobs:
|
||||
- name: Label PR based on changed files
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
_PR_NUMBER: ${{ inputs.pr-number || github.event.pull_request.number }}
|
||||
_LABEL_MODE: ${{ inputs.mode && format('--{0}', inputs.mode) || steps.label-mode.outputs.label_mode }}
|
||||
_DRY_RUN: ${{ inputs.dry-run == true && '--dry-run' || '' }}
|
||||
_PR_LABELS: ${{ toJSON(github.event.pull_request.labels) }}
|
||||
run: |
|
||||
if [ -z "$_PR_LABELS" ] || [ "$_PR_LABELS" = "null" ] || [ "$_PR_LABELS" = "[]" ]; then
|
||||
echo "🔍 No current PR labels found, retrieving PR data for PR #$_PR_NUMBER..."
|
||||
_PR_LABELS=$(gh pr view "$_PR_NUMBER" --json labels --jq '.labels')
|
||||
fi
|
||||
echo "🔍 Labeling PR #$_PR_NUMBER with mode: \"$_LABEL_MODE\" and dry-run: \"$_DRY_RUN\" and current PR labels: \"$_PR_LABELS\"..."
|
||||
echo "🐍 Running label-pr.py script..."
|
||||
echo ""
|
||||
python3 .github/scripts/label-pr.py "$_PR_NUMBER" "$_PR_LABELS" "$_LABEL_MODE" "$_DRY_RUN"
|
||||
echo "🔍 Labeling PR #$_PR_NUMBER with mode: $_LABEL_MODE and dry-run: $_DRY_RUN"
|
||||
python3 .github/scripts/label-pr.py "$_PR_NUMBER" "$_LABEL_MODE" "$_DRY_RUN"
|
||||
|
||||
|
||||
6
.github/workflows/sdlc-sdk-update.yml
vendored
6
.github/workflows/sdlc-sdk-update.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
permission-contents: write
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
fetch-depth: 0
|
||||
@@ -190,7 +190,7 @@ jobs:
|
||||
--base main \
|
||||
--head "$_BRANCH_NAME" \
|
||||
--label "automated-pr" \
|
||||
--label "t:deps")
|
||||
--label "t:ci")
|
||||
echo "## 🚀 Created PR: $PR_URL" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
9
.github/workflows/test.yml
vendored
9
.github/workflows/test.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
@@ -65,6 +65,7 @@ jobs:
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
@@ -75,7 +76,7 @@ jobs:
|
||||
bundle exec fastlane check
|
||||
|
||||
- name: Upload test reports
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: test-reports
|
||||
|
||||
3
Gemfile
3
Gemfile
@@ -14,8 +14,5 @@ gem 'logger'
|
||||
gem 'mutex_m'
|
||||
gem 'csv'
|
||||
|
||||
# Since ruby 3.4.1 these are not included in the standard library
|
||||
gem 'nkf'
|
||||
|
||||
# Starting with Ruby 3.5.0, these are not included in the standard library
|
||||
gem 'ostruct'
|
||||
|
||||
49
Gemfile.lock
49
Gemfile.lock
@@ -1,15 +1,18 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
CFPropertyList (3.0.7)
|
||||
base64
|
||||
nkf
|
||||
rexml
|
||||
abbrev (0.1.2)
|
||||
addressable (2.8.8)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1211.0)
|
||||
aws-sdk-core (3.241.4)
|
||||
aws-partitions (1.1181.0)
|
||||
aws-sdk-core (3.236.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@@ -17,25 +20,25 @@ GEM
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.121.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sdk-kms (1.117.0)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.213.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sdk-s3 (1.203.0)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.3.0)
|
||||
bigdecimal (4.0.1)
|
||||
bigdecimal (3.3.1)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.5)
|
||||
date (3.5.1)
|
||||
date (3.5.0)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
@@ -55,14 +58,14 @@ GEM
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday-cookie_jar (0.0.7)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (>= 1.0.0)
|
||||
http-cookie (~> 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.1)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.2.0)
|
||||
faraday-multipart (1.1.1)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (1.0.2)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
@@ -72,9 +75,8 @@ GEM
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.0)
|
||||
fastlane (2.229.0)
|
||||
fastlane (2.228.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
@@ -82,7 +84,6 @@ GEM
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
@@ -102,7 +103,6 @@ GEM
|
||||
jwt (>= 2.1.0, < 3)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3.0)
|
||||
naturally (~> 2.2)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
@@ -169,23 +169,23 @@ GEM
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.18.0)
|
||||
json (2.16.0)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.19.1)
|
||||
multi_json (1.17.0)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
nkf (0.2.0)
|
||||
optparse (0.8.1)
|
||||
optparse (0.8.0)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
public_suffix (7.0.2)
|
||||
public_suffix (6.0.2)
|
||||
rake (13.3.1)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
@@ -209,7 +209,7 @@ GEM
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
time (0.4.2)
|
||||
time (0.4.1)
|
||||
date
|
||||
trailblazer-option (0.1.2)
|
||||
tty-cursor (0.7.1)
|
||||
@@ -241,7 +241,6 @@ DEPENDENCIES
|
||||
fastlane-plugin-firebase_app_distribution
|
||||
logger
|
||||
mutex_m
|
||||
nkf
|
||||
ostruct
|
||||
time
|
||||
|
||||
@@ -249,4 +248,4 @@ RUBY VERSION
|
||||
ruby 3.4.2p28
|
||||
|
||||
BUNDLED WITH
|
||||
2.6.2
|
||||
2.6.9
|
||||
|
||||
@@ -260,8 +260,6 @@ dependencies {
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
implementation(libs.bitwarden.sdk)
|
||||
implementation(libs.bumptech.glide)
|
||||
implementation(libs.bumptech.glide.okhttp)
|
||||
ksp(libs.bumptech.glide.compiler)
|
||||
implementation(libs.google.hilt.android)
|
||||
ksp(libs.google.hilt.compiler)
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
@@ -301,6 +299,18 @@ dependencies {
|
||||
testImplementation(libs.square.turbine)
|
||||
}
|
||||
|
||||
tasks {
|
||||
withType<Test> {
|
||||
useJUnitPlatform()
|
||||
maxHeapSize = "2g"
|
||||
maxParallelForks = Runtime.getRuntime().availableProcessors()
|
||||
jvmArgs = jvmArgs.orEmpty() + "-XX:+UseParallelGC" +
|
||||
// Explicitly setting the user Country and Language because tests assume en-US
|
||||
"-Duser.country=US" +
|
||||
"-Duser.language=en"
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
// Disable Fdroid-specific tasks that we want to exclude
|
||||
val fdroidTasksToDisable = tasks.withType<GoogleServicesTask>() +
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "2835802f9de260f6f5109c81081e9b46",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "organization_events",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `organization_event_type` TEXT NOT NULL, `cipher_id` TEXT, `date` INTEGER NOT NULL, `organization_id` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "organizationEventType",
|
||||
"columnName": "organization_event_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cipherId",
|
||||
"columnName": "cipher_id",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "date",
|
||||
"columnName": "date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "organizationId",
|
||||
"columnName": "organization_id",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_organization_events_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_organization_events_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2835802f9de260f6f5109c81081e9b46')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 9,
|
||||
"identityHash": "61353072161e3101ade140e2c4b65495",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "ciphers",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `has_totp` INTEGER NOT NULL DEFAULT 1, `cipher_type` TEXT NOT NULL, `cipher_json` TEXT NOT NULL, `organization_id` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasTotp",
|
||||
"columnName": "has_totp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "cipherType",
|
||||
"columnName": "cipher_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cipherJson",
|
||||
"columnName": "cipher_json",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "organizationId",
|
||||
"columnName": "organization_id",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_ciphers_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_ciphers_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
},
|
||||
{
|
||||
"name": "index_ciphers_user_id_organization_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id",
|
||||
"organization_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_ciphers_user_id_organization_id` ON `${TABLE_NAME}` (`user_id`, `organization_id`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "collections",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `organization_id` TEXT NOT NULL, `should_hide_passwords` INTEGER NOT NULL, `name` TEXT NOT NULL, `external_id` TEXT, `read_only` INTEGER NOT NULL, `manage` INTEGER, `default_user_collection_email` TEXT, `type` TEXT NOT NULL DEFAULT '0', PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "organizationId",
|
||||
"columnName": "organization_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "shouldHidePasswords",
|
||||
"columnName": "should_hide_passwords",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "externalId",
|
||||
"columnName": "external_id",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isReadOnly",
|
||||
"columnName": "read_only",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "canManage",
|
||||
"columnName": "manage",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "defaultUserCollectionEmail",
|
||||
"columnName": "default_user_collection_email",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'0'"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_collections_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "domains",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `domains_json` TEXT, PRIMARY KEY(`user_id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "domainsJson",
|
||||
"columnName": "domains_json",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `name` TEXT, `revision_date` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "revisionDate",
|
||||
"columnName": "revision_date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_folders_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_folders_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "sends",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `send_type` TEXT NOT NULL, `send_json` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sendType",
|
||||
"columnName": "send_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sendJson",
|
||||
"columnName": "send_json",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_sends_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_sends_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '61353072161e3101ade140e2c4b65495')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="horizons.permission.HEADSET_CAMERA" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.READ_USER_DICTIONARY" />
|
||||
@@ -31,6 +30,10 @@
|
||||
android:protectionLevel="signature|knownSigner"
|
||||
tools:targetApi="s" />
|
||||
|
||||
<permission
|
||||
android:name="${applicationId}.permission.AUTOFILL_CALLBACK"
|
||||
android:protectionLevel="signature" />
|
||||
|
||||
<application
|
||||
android:name=".BitwardenApplication"
|
||||
android:allowBackup="false"
|
||||
@@ -84,6 +87,15 @@
|
||||
<data android:host="*.bitwarden.eu" />
|
||||
<data android:pathPattern="/redirect-connector.*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSKEY" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSWORD" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSWORD" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_UNLOCK_ACCOUNT" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
@@ -111,35 +123,6 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Credential Provider Activity for handling passkey and password credential operations.
|
||||
This activity is NOT exported to protect against external apps attempting to extract
|
||||
vault credentials by sending malicious intents. Only our own PendingIntents can
|
||||
launch this activity.
|
||||
|
||||
This is a transparent trampoline activity that launches MainActivity for credential
|
||||
operations and forwards results back to the Credential Manager framework.
|
||||
Uses Theme.Translucent.NoTitleBar for invisibility while allowing normal lifecycle
|
||||
(Theme.NoDisplay requires finish() before onResume(), incompatible with ActivityResult).
|
||||
|
||||
Note: Unlike AuthCallbackActivity, this does NOT use noHistory="true" because it
|
||||
must remain in the back stack to receive the ActivityResult callback from
|
||||
MainActivity. -->
|
||||
<activity
|
||||
android:name=".CredentialProviderActivity"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
||||
<intent-filter>
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSKEY" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSWORD" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSWORD" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_UNLOCK_ACCOUNT" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".AccessibilityActivity"
|
||||
android:exported="false"
|
||||
@@ -152,7 +135,8 @@
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:noHistory="true"
|
||||
android:theme="@style/AutofillCallbackTheme" />
|
||||
android:theme="@style/AutofillCallbackTheme"
|
||||
android:permission="${applicationId}.permission.AUTOFILL_CALLBACK" />
|
||||
|
||||
<activity
|
||||
android:name=".AuthCallbackActivity"
|
||||
@@ -160,19 +144,6 @@
|
||||
android:launchMode="singleTop"
|
||||
android:noHistory="true"
|
||||
android:theme="@android:style/Theme.NoDisplay">
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="https" />
|
||||
<data android:host="bitwarden.com" />
|
||||
<data android:host="bitwarden.eu" />
|
||||
<data android:pathPattern="/duo-callback" />
|
||||
<data android:pathPattern="/sso-callback" />
|
||||
<data android:pathPattern="/webauthn-callback" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
@@ -295,7 +266,7 @@
|
||||
android:name="com.x8bit.bitwarden.AutofillTileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_notification"
|
||||
android:label="@string/autofill_verb"
|
||||
android:label="@string/autofill_title"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
tools:ignore="MissingClass">
|
||||
<intent-filter>
|
||||
|
||||
@@ -1,33 +1,5 @@
|
||||
{
|
||||
"apps": [
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "com.iode.firefox",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "C9:96:DA:AB:86:A8:CD:32:53:77:49:A5:EE:1D:C2:F9:84:F2:9D:43:F3:06:7D:2C:0A:54:BF:8B:BF:AB:62:C0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "eu.weblibre.gecko",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "BB:2A:97:F5:61:53:35:C9:E5:7C:86:6F:1C:30:ED:4F:D7:D7:BD:DC:BC:BC:06:68:FE:93:A5:79:17:3D:3D:2D"
|
||||
},
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "8F:52:6E:1E:53:D6:BD:4D:FB:F4:F4:B9:3C:2A:91:EC:B5:CB:8D:A5:E1:4A:D9:4C:25:70:E1:E3:C7:13:52:7F"
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
@@ -40,6 +12,18 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "org.chromium.chrome",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "A8:56:48:50:79:BC:B3:57:BF:BE:69:BA:19:A9:BA:43:CD:0A:D9:AB:22:67:52:C7:80:B6:88:8A:FD:48:21:6B"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
|
||||
@@ -815,18 +815,6 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "com.zoho.primeum.stable",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "A9:D6:D0:A2:AF:DB:15:84:9B:8C:D3:1D:51:FE:73:B8:E1:B1:70:BA:A5:70:C2:F8:F2:A3:F8:65:28:29:CB:BD"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.LogsManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConfigManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
@@ -23,6 +24,9 @@ class BitwardenApplication : Application() {
|
||||
@Inject
|
||||
lateinit var logsManager: LogsManager
|
||||
|
||||
@Inject
|
||||
lateinit var networkConnectionManager: NetworkConnectionManager
|
||||
|
||||
@Inject
|
||||
lateinit var networkConfigManager: NetworkConfigManager
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package com.x8bit.bitwarden
|
||||
|
||||
import android.app.ComponentCaller
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.util.validate
|
||||
import com.x8bit.bitwarden.data.credentials.BitwardenCredentialProviderService
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
/**
|
||||
* Transparent trampoline activity for handling credential provider operations.
|
||||
*
|
||||
* This activity is declared as `exported="false"` in the manifest to ensure only
|
||||
* our own PendingIntents can launch it. This protects against external apps attempting
|
||||
* to extract vault credentials by sending malicious intents via CredentialManager.
|
||||
*
|
||||
* All credential flows (FIDO2 passkeys, password credentials) are routed through this
|
||||
* activity when triggered by the Android CredentialManager framework via our
|
||||
* [BitwardenCredentialProviderService].
|
||||
*
|
||||
* ## Architecture
|
||||
*
|
||||
* This activity does not host any UI itself. It acts as a trampoline that:
|
||||
* 1. Receives the credential intent from the CredentialManager framework
|
||||
* 2. Sets the pending credential request via [CredentialProviderViewModel], which stores
|
||||
* it in `CredentialProviderRequestManager` for secure relay to [MainViewModel]
|
||||
* 3. Launches [MainActivity] to handle the actual credential UI
|
||||
* 4. Forwards the result back to the CredentialManager framework
|
||||
*
|
||||
* This preserves the single-Activity architecture where all UI is hosted by MainActivity,
|
||||
* while still allowing the CredentialManager framework to receive results properly.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
@AndroidEntryPoint
|
||||
class CredentialProviderActivity : ComponentActivity() {
|
||||
|
||||
private val viewModel: CredentialProviderViewModel by viewModels()
|
||||
|
||||
/**
|
||||
* Launcher for MainActivity that forwards the result back to Credential Manager.
|
||||
*/
|
||||
private val mainActivityLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
// Forward result back to Credential Manager framework
|
||||
setResult(result.resultCode, result.data)
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
intent = intent.validate()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
// Process credential intent (sets pending request on CredentialProviderRequestManager)
|
||||
viewModel.trySendAction(CredentialProviderAction.ReceiveFirstIntent(intent))
|
||||
launchMainActivityForResult()
|
||||
}
|
||||
// On restoration (process death), result comes via mainActivityLauncher callback
|
||||
}
|
||||
|
||||
private fun launchMainActivityForResult() {
|
||||
val mainIntent = Intent(this, MainActivity::class.java).apply {
|
||||
// Pending credential request is retrieved by MainViewModel from
|
||||
// CredentialProviderRequestManager, triggering appropriate navigation.
|
||||
// CredentialProviderCompletionManager handles setResult/finish.
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
}
|
||||
mainActivityLauncher.launch(mainIntent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
val newIntent = intent.validate()
|
||||
super.onNewIntent(newIntent)
|
||||
viewModel.trySendAction(CredentialProviderAction.ReceiveNewIntent(newIntent))
|
||||
launchMainActivityForResult()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent, caller: ComponentCaller) {
|
||||
val newIntent = intent.validate()
|
||||
super.onNewIntent(newIntent, caller)
|
||||
viewModel.trySendAction(CredentialProviderAction.ReceiveNewIntent(newIntent))
|
||||
launchMainActivityForResult()
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package com.x8bit.bitwarden
|
||||
|
||||
import android.content.Intent
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
|
||||
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
|
||||
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
|
||||
import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest
|
||||
import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionRequest
|
||||
import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
|
||||
import com.x8bit.bitwarden.data.credentials.model.ProviderGetPasswordCredentialRequest
|
||||
import com.x8bit.bitwarden.data.credentials.util.getCreateCredentialRequestOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.util.getFido2AssertionRequestOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.util.getGetCredentialsRequestOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.util.getProviderGetPasswordRequestOrNull
|
||||
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* A view model that handles credential provider operations for [CredentialProviderActivity].
|
||||
*
|
||||
* This ViewModel processes credential-related intents and sets the pending credential request
|
||||
* on [CredentialProviderRequestManager] for relay to [MainViewModel]. This ensures credential
|
||||
* data is never passed through intent extras to exported activities, providing security
|
||||
* hardening against malicious intent attacks.
|
||||
*
|
||||
* Since [CredentialProviderActivity] is a transparent trampoline with no UI, this ViewModel only
|
||||
* handles intent processing. All UI state management (theme, feature flags, auth flows) is
|
||||
* handled by [MainActivity].
|
||||
*
|
||||
* @see RootNavViewModel for navigation based on SpecialCircumstance.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CredentialProviderViewModel @Inject constructor(
|
||||
private val credentialProviderRequestManager: CredentialProviderRequestManager,
|
||||
private val authRepository: AuthRepository,
|
||||
private val bitwardenCredentialManager: BitwardenCredentialManager,
|
||||
) : BaseViewModel<Unit, Unit, CredentialProviderAction>(initialState = Unit) {
|
||||
|
||||
override fun handleAction(action: CredentialProviderAction) {
|
||||
when (action) {
|
||||
is CredentialProviderAction.ReceiveFirstIntent -> handleIntent(action.intent)
|
||||
is CredentialProviderAction.ReceiveNewIntent -> handleIntent(action.intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent) {
|
||||
intent.getCreateCredentialRequestOrNull()?.let { handleCreateCredential(it) }
|
||||
?: intent.getFido2AssertionRequestOrNull()?.let { handleFido2Assertion(it) }
|
||||
?: intent.getProviderGetPasswordRequestOrNull()?.let { handlePasswordGet(it) }
|
||||
?: intent.getGetCredentialsRequestOrNull()?.let { handleGetCredentials(it) }
|
||||
}
|
||||
|
||||
private fun handleCreateCredential(request: CreateCredentialRequest) {
|
||||
bitwardenCredentialManager.isUserVerified = request.isUserPreVerified
|
||||
|
||||
// Switch accounts if the selected user is not the active user
|
||||
if (authRepository.activeUserId != null &&
|
||||
authRepository.activeUserId != request.userId
|
||||
) {
|
||||
authRepository.switchAccount(request.userId)
|
||||
}
|
||||
|
||||
credentialProviderRequestManager.setPendingCredentialRequest(
|
||||
CredentialProviderRequest.CreateCredential(request),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleFido2Assertion(request: Fido2CredentialAssertionRequest) {
|
||||
// Set the user's verification status when a new FIDO 2 request is received
|
||||
bitwardenCredentialManager.isUserVerified = request.isUserPreVerified
|
||||
|
||||
credentialProviderRequestManager.setPendingCredentialRequest(
|
||||
CredentialProviderRequest.Fido2Assertion(request),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handlePasswordGet(request: ProviderGetPasswordCredentialRequest) {
|
||||
// Set the user's verification status when a new GetPassword request is received
|
||||
bitwardenCredentialManager.isUserVerified = request.isUserPreVerified
|
||||
|
||||
credentialProviderRequestManager.setPendingCredentialRequest(
|
||||
CredentialProviderRequest.GetPassword(request),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleGetCredentials(request: GetCredentialsRequest) {
|
||||
credentialProviderRequestManager.setPendingCredentialRequest(
|
||||
CredentialProviderRequest.GetCredentials(request),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models actions for the [CredentialProviderViewModel].
|
||||
*/
|
||||
sealed class CredentialProviderAction {
|
||||
|
||||
/**
|
||||
* Receive the first intent when the activity is created.
|
||||
*/
|
||||
data class ReceiveFirstIntent(val intent: Intent) : CredentialProviderAction()
|
||||
|
||||
/**
|
||||
* Receive a new intent when the activity receives onNewIntent.
|
||||
*/
|
||||
data class ReceiveNewIntent(val intent: Intent) : CredentialProviderAction()
|
||||
}
|
||||
@@ -12,11 +12,9 @@ import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.browser.auth.AuthTabIntent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
@@ -122,8 +120,6 @@ class MainActivity : AppCompatActivity() {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = RootNavigationRoute,
|
||||
modifier = Modifier
|
||||
.background(color = BitwardenTheme.colorScheme.background.primary),
|
||||
) {
|
||||
// Both root navigation and debug menu exist at this top level.
|
||||
// The debug menu can appear on top of the rest of the app without
|
||||
|
||||
@@ -26,8 +26,11 @@ import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySele
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
|
||||
import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull
|
||||
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
|
||||
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
|
||||
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
|
||||
import com.x8bit.bitwarden.data.credentials.util.getCreateCredentialRequestOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.util.getFido2AssertionRequestOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.util.getGetCredentialsRequestOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.util.getProviderGetPasswordRequestOrNull
|
||||
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
|
||||
@@ -39,7 +42,6 @@ import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.platform.util.isAddTotpLoginItemFromAuthenticator
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavViewModel
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
|
||||
import com.x8bit.bitwarden.ui.platform.model.FeatureFlagsState
|
||||
import com.x8bit.bitwarden.ui.platform.util.isAccountSecurityShortcut
|
||||
@@ -78,7 +80,7 @@ class MainViewModel @Inject constructor(
|
||||
private val addTotpItemFromAuthenticatorManager: AddTotpItemFromAuthenticatorManager,
|
||||
private val specialCircumstanceManager: SpecialCircumstanceManager,
|
||||
private val garbageCollectionManager: GarbageCollectionManager,
|
||||
private val credentialProviderRequestManager: CredentialProviderRequestManager,
|
||||
private val bitwardenCredentialManager: BitwardenCredentialManager,
|
||||
private val shareManager: ShareManager,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val vaultRepository: VaultRepository,
|
||||
@@ -312,9 +314,11 @@ class MainViewModel @Inject constructor(
|
||||
val hasVaultShortcut = intent.isMyVaultShortcut
|
||||
val hasAccountSecurityShortcut = intent.isAccountSecurityShortcut
|
||||
val completeRegistrationData = intent.getCompleteRegistrationDataIntentOrNull()
|
||||
val createCredentialRequest = intent.getCreateCredentialRequestOrNull()
|
||||
val getCredentialsRequest = intent.getGetCredentialsRequestOrNull()
|
||||
val fido2AssertCredentialRequest = intent.getFido2AssertionRequestOrNull()
|
||||
val providerGetPasswordRequest = intent.getProviderGetPasswordRequestOrNull()
|
||||
val importCredentialsRequest = intent.getProviderImportCredentialsRequest()
|
||||
val credentialProviderRequest =
|
||||
credentialProviderRequestManager.getPendingCredentialRequest()
|
||||
when {
|
||||
passwordlessRequestData != null -> {
|
||||
authRepository.activeUserId?.let {
|
||||
@@ -372,6 +376,59 @@ class MainViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
createCredentialRequest != null -> {
|
||||
// Set the user's verification status when a new FIDO 2 request is received to force
|
||||
// explicit verification if the user's vault is unlocked when the request is
|
||||
// received.
|
||||
bitwardenCredentialManager.isUserVerified =
|
||||
createCredentialRequest.isUserPreVerified
|
||||
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.ProviderCreateCredential(
|
||||
createCredentialRequest = createCredentialRequest,
|
||||
)
|
||||
|
||||
// Switch accounts if the selected user is not the active user.
|
||||
if (authRepository.activeUserId != null &&
|
||||
authRepository.activeUserId != createCredentialRequest.userId
|
||||
) {
|
||||
authRepository.switchAccount(createCredentialRequest.userId)
|
||||
}
|
||||
}
|
||||
|
||||
fido2AssertCredentialRequest != null -> {
|
||||
// Set the user's verification status when a new FIDO 2 request is received to force
|
||||
// explicit verification if the user's vault is unlocked when the request is
|
||||
// received.
|
||||
bitwardenCredentialManager.isUserVerified =
|
||||
fido2AssertCredentialRequest.isUserPreVerified
|
||||
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.Fido2Assertion(
|
||||
fido2AssertionRequest = fido2AssertCredentialRequest,
|
||||
)
|
||||
}
|
||||
|
||||
providerGetPasswordRequest != null -> {
|
||||
// Set the user's verification status when a new GetPassword request is
|
||||
// received to force explicit verification if the user's vault is
|
||||
// unlocked when the request is received.
|
||||
bitwardenCredentialManager.isUserVerified =
|
||||
providerGetPasswordRequest.isUserPreVerified
|
||||
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.ProviderGetPasswordRequest(
|
||||
passwordGetRequest = providerGetPasswordRequest,
|
||||
)
|
||||
}
|
||||
|
||||
getCredentialsRequest != null -> {
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.ProviderGetCredentials(
|
||||
getCredentialsRequest = getCredentialsRequest,
|
||||
)
|
||||
}
|
||||
|
||||
hasGeneratorShortcut -> {
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.GeneratorShortcut
|
||||
@@ -395,47 +452,6 @@ class MainViewModel @Inject constructor(
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
credentialProviderRequest != null -> {
|
||||
handleCredentialRequest(credentialProviderRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a credential request relayed from [CredentialProviderActivity] via
|
||||
* [CredentialProviderRequestManager].
|
||||
*
|
||||
* This method converts the [CredentialProviderRequest] into the appropriate
|
||||
* [SpecialCircumstance] for routing by [RootNavViewModel]. The credential data is trusted
|
||||
* because it was set by our own [CredentialProviderActivity] through the internal manager,
|
||||
* not parsed from intent extras.
|
||||
*/
|
||||
private fun handleCredentialRequest(request: CredentialProviderRequest) {
|
||||
specialCircumstanceManager.specialCircumstance = when (request) {
|
||||
is CredentialProviderRequest.CreateCredential -> {
|
||||
SpecialCircumstance.ProviderCreateCredential(
|
||||
createCredentialRequest = request.request,
|
||||
)
|
||||
}
|
||||
|
||||
is CredentialProviderRequest.Fido2Assertion -> {
|
||||
SpecialCircumstance.Fido2Assertion(
|
||||
fido2AssertionRequest = request.request,
|
||||
)
|
||||
}
|
||||
|
||||
is CredentialProviderRequest.GetPassword -> {
|
||||
SpecialCircumstance.ProviderGetPasswordRequest(
|
||||
passwordGetRequest = request.request,
|
||||
)
|
||||
}
|
||||
|
||||
is CredentialProviderRequest.GetCredentials -> {
|
||||
SpecialCircumstance.ProviderGetCredentials(
|
||||
getCredentialsRequest = request.request,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -159,11 +159,11 @@ class AuthDiskSourceImpl(
|
||||
storeAuthenticatorSyncUnlockKey(userId = userId, authenticatorSyncUnlockKey = null)
|
||||
storeShowImportLogins(userId = userId, showImportLogins = null)
|
||||
storeLastLockTimestamp(userId = userId, lastLockTimestamp = null)
|
||||
storeEncryptedPin(userId = userId, encryptedPin = null)
|
||||
storePinProtectedUserKey(userId = userId, pinProtectedUserKey = null)
|
||||
storePinProtectedUserKeyEnvelope(userId = userId, pinProtectedUserKeyEnvelope = null)
|
||||
|
||||
// Certain values are never removed as required by the feature requirements:
|
||||
// * EncryptedPin
|
||||
// * PinProtectedUserKey
|
||||
// * PinProtectedUserKeyEnvelope
|
||||
// * DeviceKey
|
||||
// * PendingAuthRequest
|
||||
// * OnboardingStatus
|
||||
@@ -373,10 +373,7 @@ class AuthDiskSourceImpl(
|
||||
inMemoryOnly: Boolean,
|
||||
) {
|
||||
inMemoryPinProtectedUserKeyEnvelopes[userId] = pinProtectedUserKeyEnvelope
|
||||
if (inMemoryOnly) {
|
||||
getMutablePinProtectedUserKeyEnvelopeFlow(userId).tryEmit(pinProtectedUserKeyEnvelope)
|
||||
return
|
||||
}
|
||||
if (inMemoryOnly) return
|
||||
putString(
|
||||
key = PIN_PROTECTED_USER_KEY_KEY_ENVELOPE.appendIdentifier(userId),
|
||||
value = pinProtectedUserKeyEnvelope,
|
||||
|
||||
@@ -30,7 +30,7 @@ class AuthSdkSourceImpl(
|
||||
getClient()
|
||||
.auth()
|
||||
.newAuthRequest(
|
||||
email = email.lowercase(),
|
||||
email = email,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ class AuthSdkSourceImpl(
|
||||
.platform()
|
||||
.fingerprint(
|
||||
req = FingerprintRequest(
|
||||
fingerprintMaterial = email.lowercase(),
|
||||
fingerprintMaterial = email,
|
||||
publicKey = publicKey,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.isActive
|
||||
import java.time.Clock
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
private const val PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS: Long = 15L * 60L * 1_000L
|
||||
private const val PASSWORDLESS_NOTIFICATION_RETRY_INTERVAL_MILLIS: Long = 4L * 1_000L
|
||||
@@ -162,7 +163,7 @@ class AuthRequestManagerImpl(
|
||||
emit(result)
|
||||
if (result is AuthRequestUpdatesResult.Error) return@flow
|
||||
var isComplete = false
|
||||
while (currentCoroutineContext().isActive && !isComplete) {
|
||||
while (coroutineContext.isActive && !isComplete) {
|
||||
delay(PASSWORDLESS_APPROVER_INTERVAL_MILLIS)
|
||||
val updateResult = result as AuthRequestUpdatesResult.Update
|
||||
authRequestsService
|
||||
|
||||
@@ -49,14 +49,14 @@ class UserLogoutManagerImpl(
|
||||
override fun logout(userId: String, reason: LogoutReason) {
|
||||
authDiskSource.userState ?: return
|
||||
Timber.d("logout reason=$reason")
|
||||
val isSecurityStamp = reason == LogoutReason.SecurityStamp
|
||||
if (isSecurityStamp) {
|
||||
val isExpired = reason == LogoutReason.SecurityStamp
|
||||
if (isExpired) {
|
||||
showToast(message = BitwardenString.login_expired)
|
||||
}
|
||||
|
||||
val ableToSwitchToNewAccount = switchUserIfAvailable(
|
||||
currentUserId = userId,
|
||||
isSecurityStamp = isSecurityStamp,
|
||||
isExpired = isExpired,
|
||||
removeCurrentUserFromAccounts = true,
|
||||
)
|
||||
|
||||
@@ -73,24 +73,19 @@ class UserLogoutManagerImpl(
|
||||
|
||||
override fun softLogout(userId: String, reason: LogoutReason) {
|
||||
Timber.d("softLogout reason=$reason")
|
||||
val isSecurityStamp = reason == LogoutReason.SecurityStamp
|
||||
if (isSecurityStamp) {
|
||||
val isExpired = reason == LogoutReason.SecurityStamp
|
||||
if (isExpired) {
|
||||
showToast(message = BitwardenString.login_expired)
|
||||
}
|
||||
|
||||
// Save any data that will still need to be retained after otherwise clearing all data
|
||||
// Save any data that will still need to be retained after otherwise clearing all dat
|
||||
val vaultTimeoutInMinutes = settingsDiskSource.getVaultTimeoutInMinutes(userId = userId)
|
||||
val vaultTimeoutAction = settingsDiskSource.getVaultTimeoutAction(userId = userId)
|
||||
val encryptedPin = authDiskSource.getEncryptedPin(userId = userId)
|
||||
val pinProtectedUserKey = authDiskSource.getPinProtectedUserKey(userId = userId)
|
||||
val pinProtectedUserKeyEnvelope = authDiskSource.getPinProtectedUserKeyEnvelope(
|
||||
userId = userId,
|
||||
)
|
||||
|
||||
switchUserIfAvailable(
|
||||
currentUserId = userId,
|
||||
removeCurrentUserFromAccounts = false,
|
||||
isSecurityStamp = isSecurityStamp,
|
||||
isExpired = isExpired,
|
||||
)
|
||||
|
||||
clearData(userId = userId)
|
||||
@@ -107,14 +102,6 @@ class UserLogoutManagerImpl(
|
||||
vaultTimeoutAction = vaultTimeoutAction,
|
||||
)
|
||||
}
|
||||
authDiskSource.apply {
|
||||
storeEncryptedPin(userId = userId, encryptedPin = encryptedPin)
|
||||
storePinProtectedUserKey(userId = userId, pinProtectedUserKey = pinProtectedUserKey)
|
||||
storePinProtectedUserKeyEnvelope(
|
||||
userId = userId,
|
||||
pinProtectedUserKeyEnvelope = pinProtectedUserKeyEnvelope,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearData(userId: String) {
|
||||
@@ -136,7 +123,7 @@ class UserLogoutManagerImpl(
|
||||
private fun switchUserIfAvailable(
|
||||
currentUserId: String,
|
||||
removeCurrentUserFromAccounts: Boolean,
|
||||
isSecurityStamp: Boolean,
|
||||
isExpired: Boolean = false,
|
||||
): Boolean {
|
||||
val currentUserState = authDiskSource.userState ?: return false
|
||||
|
||||
@@ -148,7 +135,7 @@ class UserLogoutManagerImpl(
|
||||
|
||||
// Check if there is a new active user
|
||||
return if (updatedAccounts.isNotEmpty()) {
|
||||
if (currentUserId == currentUserState.activeUserId && !isSecurityStamp) {
|
||||
if (currentUserId == currentUserState.activeUserId && !isExpired) {
|
||||
showToast(message = BitwardenString.account_switched_automatically)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ sealed class CreateAuthRequestResult {
|
||||
) : CreateAuthRequestResult()
|
||||
|
||||
/**
|
||||
* Models the data returned when an auth request has been approved.
|
||||
* Models the data returned when a auth request has been approved.
|
||||
*/
|
||||
data class Success(
|
||||
val authRequest: AuthRequest,
|
||||
@@ -21,7 +21,7 @@ sealed class CreateAuthRequestResult {
|
||||
) : CreateAuthRequestResult()
|
||||
|
||||
/**
|
||||
* There was a generic error creating the auth request.
|
||||
* There was a generic error getting the user's auth requests.
|
||||
*/
|
||||
data class Error(
|
||||
val error: Throwable,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository
|
||||
|
||||
import com.bitwarden.network.model.GetTokenResponseJson
|
||||
import com.bitwarden.network.model.SyncResponseJson
|
||||
import com.bitwarden.network.model.TwoFactorDataModel
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
|
||||
@@ -16,7 +17,6 @@ import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.Organization
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
|
||||
@@ -26,7 +26,6 @@ import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.RevokeFromOrganizationResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
|
||||
@@ -39,18 +38,16 @@ import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
|
||||
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.AuthenticatorProvider
|
||||
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Provides an API for observing and modifying authentication state.
|
||||
* Provides an API for observing an modifying authentication state.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
interface AuthRepository :
|
||||
AuthenticatorProvider,
|
||||
AuthRequestManager,
|
||||
BiometricsEncryptionManager,
|
||||
KdfManager,
|
||||
UserStateManager {
|
||||
/**
|
||||
@@ -126,7 +123,7 @@ interface AuthRepository :
|
||||
/**
|
||||
* The organization for the active user.
|
||||
*/
|
||||
val organizations: List<Organization>
|
||||
val organizations: List<SyncResponseJson.Profile.Organization>
|
||||
|
||||
/**
|
||||
* Whether or not the welcome carousel should be displayed, based on the feature flag and
|
||||
@@ -283,7 +280,7 @@ interface AuthRepository :
|
||||
): PasswordHintResult
|
||||
|
||||
/**
|
||||
* Removes the users password from the account. This is used when migrating from master
|
||||
* Removes the users password from the account. This used used when migrating from master
|
||||
* password login to key connector login.
|
||||
*/
|
||||
suspend fun removePassword(masterPassword: String): RemovePasswordResult
|
||||
@@ -360,14 +357,14 @@ interface AuthRepository :
|
||||
suspend fun getPasswordStrength(email: String? = null, password: String): PasswordStrengthResult
|
||||
|
||||
/**
|
||||
* Validates the master password for the current logged-in user.
|
||||
* Validates the master password for the current logged in user.
|
||||
*/
|
||||
suspend fun validatePassword(password: String): ValidatePasswordResult
|
||||
|
||||
/**
|
||||
* Validates the PIN for the current logged-in user.
|
||||
* Validates the PIN for the current logged in user.
|
||||
*/
|
||||
suspend fun validatePinUserKey(pin: String): ValidatePinResult
|
||||
suspend fun validatePin(pin: String): ValidatePinResult
|
||||
|
||||
/**
|
||||
* Validates the given [password] against the master password
|
||||
@@ -385,7 +382,7 @@ interface AuthRepository :
|
||||
): SendVerificationEmailResult
|
||||
|
||||
/**
|
||||
* Validates the given [token] for the given [email]. Part of the new account registration flow.
|
||||
* Validates the given [token] for the given [email]. Part of th new account registration flow.
|
||||
*/
|
||||
suspend fun validateEmailToken(
|
||||
email: String,
|
||||
@@ -403,11 +400,4 @@ interface AuthRepository :
|
||||
suspend fun leaveOrganization(
|
||||
organizationId: String,
|
||||
): LeaveOrganizationResult
|
||||
|
||||
/**
|
||||
* Revokes self from the organization that matches the given [organizationId]
|
||||
*/
|
||||
suspend fun revokeFromOrganization(
|
||||
organizationId: String,
|
||||
): RevokeFromOrganizationResult
|
||||
}
|
||||
|
||||
@@ -2,11 +2,7 @@ package com.x8bit.bitwarden.data.auth.repository
|
||||
|
||||
import com.bitwarden.core.AuthRequestMethod
|
||||
import com.bitwarden.core.InitUserCryptoMethod
|
||||
import com.bitwarden.core.RegisterTdeKeyResponse
|
||||
import com.bitwarden.core.WrappedAccountCryptographicState
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.core.data.manager.toast.ToastManager
|
||||
import com.bitwarden.core.data.repository.error.MissingPropertyException
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.core.data.util.asFailure
|
||||
import com.bitwarden.core.data.util.asSuccess
|
||||
@@ -16,7 +12,6 @@ import com.bitwarden.crypto.Kdf
|
||||
import com.bitwarden.data.datasource.disk.ConfigDiskSource
|
||||
import com.bitwarden.data.repository.util.toEnvironmentUrls
|
||||
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
|
||||
import com.bitwarden.network.model.CreateAccountKeysResponseJson
|
||||
import com.bitwarden.network.model.DeleteAccountResponseJson
|
||||
import com.bitwarden.network.model.GetTokenResponseJson
|
||||
import com.bitwarden.network.model.IdentityTokenAuthModel
|
||||
@@ -48,7 +43,6 @@ import com.bitwarden.network.service.HaveIBeenPwnedService
|
||||
import com.bitwarden.network.service.IdentityService
|
||||
import com.bitwarden.network.service.OrganizationService
|
||||
import com.bitwarden.network.util.isSslHandShakeError
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
|
||||
@@ -74,7 +68,6 @@ import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.Organization
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
|
||||
@@ -84,7 +77,6 @@ import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.RevokeFromOrganizationResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
|
||||
@@ -99,7 +91,6 @@ import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toRemovedPasswordUserStateJson
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
|
||||
@@ -109,8 +100,8 @@ import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITER
|
||||
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
|
||||
import com.x8bit.bitwarden.data.auth.util.toSdkParams
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.error.MissingPropertyException
|
||||
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
|
||||
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.LogsManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.PushManager
|
||||
@@ -121,7 +112,6 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockError
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.createWrappedAccountCryptographicState
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.toSdkMasterPasswordUnlock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -167,20 +157,17 @@ class AuthRepositoryImpl(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val vaultRepository: VaultRepository,
|
||||
private val authRequestManager: AuthRequestManager,
|
||||
private val biometricsEncryptionManager: BiometricsEncryptionManager,
|
||||
private val keyConnectorManager: KeyConnectorManager,
|
||||
private val trustedDeviceManager: TrustedDeviceManager,
|
||||
private val userLogoutManager: UserLogoutManager,
|
||||
private val policyManager: PolicyManager,
|
||||
private val userStateManager: UserStateManager,
|
||||
private val kdfManager: KdfManager,
|
||||
private val toastManager: ToastManager,
|
||||
logsManager: LogsManager,
|
||||
pushManager: PushManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
) : AuthRepository,
|
||||
AuthRequestManager by authRequestManager,
|
||||
BiometricsEncryptionManager by biometricsEncryptionManager,
|
||||
KdfManager by kdfManager,
|
||||
UserStateManager by userStateManager {
|
||||
/**
|
||||
@@ -293,11 +280,8 @@ class AuthRepositoryImpl(
|
||||
?.profile
|
||||
?.forcePasswordResetReason
|
||||
|
||||
override val organizations: List<Organization>
|
||||
get() = activeUserId
|
||||
?.let { authDiskSource.getOrganizations(it) }
|
||||
.orEmpty()
|
||||
.toOrganizations()
|
||||
override val organizations: List<SyncResponseJson.Profile.Organization>
|
||||
get() = activeUserId?.let { authDiskSource.getOrganizations(it) }.orEmpty()
|
||||
|
||||
override val showWelcomeCarousel: Boolean
|
||||
get() = !settingsRepository.hasUserLoggedInOrCreatedAccount
|
||||
@@ -470,32 +454,42 @@ class AuthRepositoryImpl(
|
||||
.getShouldTrustDevice(userId = userId) == true,
|
||||
)
|
||||
}
|
||||
.flatMap { registerTdeKeyResponse ->
|
||||
.flatMap { keys ->
|
||||
accountsService
|
||||
.createAccountKeys(
|
||||
publicKey = registerTdeKeyResponse.publicKey,
|
||||
encryptedPrivateKey = registerTdeKeyResponse.privateKey,
|
||||
publicKey = keys.publicKey,
|
||||
encryptedPrivateKey = keys.privateKey,
|
||||
)
|
||||
.map { createAccountKeysResponse ->
|
||||
registerTdeKeyResponse to createAccountKeysResponse
|
||||
}
|
||||
.map { keys }
|
||||
}
|
||||
.flatMap { (registerTdeKeyResponse, createAccountKeysResponse) ->
|
||||
.flatMap { keys ->
|
||||
organizationService
|
||||
.organizationResetPasswordEnroll(
|
||||
organizationId = orgAutoEnrollStatus.organizationId,
|
||||
userId = userId,
|
||||
passwordHash = null,
|
||||
resetPasswordKey = registerTdeKeyResponse.adminReset,
|
||||
resetPasswordKey = keys.adminReset,
|
||||
)
|
||||
.map { registerTdeKeyResponse to createAccountKeysResponse }
|
||||
.map { keys }
|
||||
}
|
||||
.onSuccess { (registerTdeKeyResponse, createAccountKeysResponse) ->
|
||||
createNewSsoUserSuccess(
|
||||
.onSuccess { keys ->
|
||||
// TDE and SSO user creation still uses crypto-v1. These users are not
|
||||
// expected to have the AEAD keys so we only store the private key for now.
|
||||
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
|
||||
// for more details.
|
||||
authDiskSource.storePrivateKey(
|
||||
userId = userId,
|
||||
createAccountKeysResponse = createAccountKeysResponse,
|
||||
registerTdeKeyResponse = registerTdeKeyResponse,
|
||||
privateKey = keys.privateKey,
|
||||
)
|
||||
// Order matters here, we need to make sure that the vault is unlocked
|
||||
// before we trust the device, to avoid state-base navigation issues.
|
||||
vaultRepository.syncVaultState(userId = userId)
|
||||
keys.deviceKey?.let { trustDeviceResponse ->
|
||||
trustedDeviceManager.trustThisDevice(
|
||||
userId = userId,
|
||||
trustDeviceResponse = trustDeviceResponse,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.fold(
|
||||
@@ -504,37 +498,6 @@ class AuthRepositoryImpl(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores all the relevant data from a successful creation of an SSO user. The data is stored
|
||||
* while in an [UserStateManager.userStateTransaction] to ensure the `UserState` is only
|
||||
* updated once after data stored.
|
||||
*/
|
||||
private suspend fun createNewSsoUserSuccess(
|
||||
userId: String,
|
||||
createAccountKeysResponse: CreateAccountKeysResponseJson,
|
||||
registerTdeKeyResponse: RegisterTdeKeyResponse,
|
||||
): Unit = userStateManager.userStateTransaction {
|
||||
authDiskSource.storeAccountKeys(
|
||||
userId = userId,
|
||||
accountKeys = createAccountKeysResponse.accountKeys,
|
||||
)
|
||||
// TDE and SSO user creation still uses crypto-v1. These users are not
|
||||
// expected to have the AEAD keys so we only store the private key for now.
|
||||
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
|
||||
// for more details.
|
||||
authDiskSource.storePrivateKey(
|
||||
userId = userId,
|
||||
privateKey = registerTdeKeyResponse.privateKey,
|
||||
)
|
||||
vaultRepository.syncVaultState(userId = userId)
|
||||
registerTdeKeyResponse.deviceKey?.let { trustDeviceResponse ->
|
||||
trustedDeviceManager.trustThisDevice(
|
||||
userId = userId,
|
||||
trustDeviceResponse = trustDeviceResponse,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun completeTdeLogin(
|
||||
requestPrivateKey: String,
|
||||
asymmetricalKey: String,
|
||||
@@ -551,7 +514,6 @@ class AuthRepositoryImpl(
|
||||
)
|
||||
val signingKey = accountKeys?.signatureKeyPair?.wrappedSigningKey
|
||||
val securityState = accountKeys?.securityState?.securityState
|
||||
val signedPublicKey = accountKeys?.publicKeyEncryptionKeyPair?.signedPublicKey
|
||||
|
||||
checkForVaultUnlockError(
|
||||
onVaultUnlockError = { error ->
|
||||
@@ -559,13 +521,10 @@ class AuthRepositoryImpl(
|
||||
},
|
||||
) {
|
||||
unlockVault(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = securityState,
|
||||
signingKey = signingKey,
|
||||
signedPublicKey = signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
privateKey = privateKey,
|
||||
signingKey = signingKey,
|
||||
securityState = securityState,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
|
||||
requestPrivateKey = requestPrivateKey,
|
||||
method = AuthRequestMethod.UserKey(protectedUserKey = asymmetricalKey),
|
||||
@@ -983,8 +942,8 @@ class AuthRepositoryImpl(
|
||||
val keyConnectorUrl = organizations
|
||||
.find {
|
||||
it.shouldUseKeyConnector &&
|
||||
it.role != OrganizationType.OWNER &&
|
||||
it.role != OrganizationType.ADMIN
|
||||
it.type != OrganizationType.OWNER &&
|
||||
it.type != OrganizationType.ADMIN
|
||||
}
|
||||
?.keyConnectorUrl
|
||||
?: return RemovePasswordResult.Error(
|
||||
@@ -1046,10 +1005,9 @@ class AuthRepositoryImpl(
|
||||
onSuccess = { it },
|
||||
)
|
||||
}
|
||||
val userId = activeAccount.profile.userId
|
||||
return vaultSdkSource
|
||||
.updatePassword(
|
||||
userId = userId,
|
||||
userId = activeAccount.profile.userId,
|
||||
newPassword = newPassword,
|
||||
)
|
||||
.flatMap { updatePasswordResponse ->
|
||||
@@ -1075,15 +1033,14 @@ class AuthRepositoryImpl(
|
||||
)
|
||||
.onSuccess { passwordHash ->
|
||||
authDiskSource.storeMasterPasswordHash(
|
||||
userId = userId,
|
||||
userId = activeAccount.profile.userId,
|
||||
passwordHash = passwordHash,
|
||||
)
|
||||
}
|
||||
|
||||
toastManager.show(BitwardenString.updated_master_password)
|
||||
// Log out the user after successful password reset.
|
||||
// This clears all user state including forcePasswordResetReason.
|
||||
logout(reason = LogoutReason.PasswordReset, userId = userId)
|
||||
logout(reason = LogoutReason.PasswordReset)
|
||||
|
||||
// Return the success.
|
||||
ResetPasswordResult.Success
|
||||
@@ -1330,7 +1287,7 @@ class AuthRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun validatePinUserKey(pin: String): ValidatePinResult {
|
||||
override suspend fun validatePin(pin: String): ValidatePinResult {
|
||||
val activeAccount = authDiskSource
|
||||
.userState
|
||||
?.activeAccount
|
||||
@@ -1339,13 +1296,13 @@ class AuthRepositoryImpl(
|
||||
val pinProtectedUserKeyEnvelope = authDiskSource
|
||||
.getPinProtectedUserKeyEnvelope(userId = activeAccount.userId)
|
||||
?: return ValidatePinResult.Error(
|
||||
error = MissingPropertyException("Pin Protected User Key Envelope"),
|
||||
error = MissingPropertyException("Pin Protected User Key"),
|
||||
)
|
||||
return vaultSdkSource
|
||||
.validatePinUserKey(
|
||||
.validatePin(
|
||||
userId = activeAccount.userId,
|
||||
pin = pin,
|
||||
pinProtectedUserKeyEnvelope = pinProtectedUserKeyEnvelope,
|
||||
pinProtectedUserKey = pinProtectedUserKeyEnvelope,
|
||||
)
|
||||
.fold(
|
||||
onSuccess = { ValidatePinResult.Success(isValid = it) },
|
||||
@@ -1399,10 +1356,10 @@ class AuthRepositoryImpl(
|
||||
)
|
||||
.fold(
|
||||
onSuccess = {
|
||||
when (it) {
|
||||
when (val json = it) {
|
||||
VerifyEmailTokenResponseJson.Valid -> EmailTokenResult.Success
|
||||
is VerifyEmailTokenResponseJson.Invalid -> {
|
||||
EmailTokenResult.Error(message = it.message, error = null)
|
||||
EmailTokenResult.Error(message = json.message, error = null)
|
||||
}
|
||||
|
||||
VerifyEmailTokenResponseJson.TokenExpired -> EmailTokenResult.Expired
|
||||
@@ -1427,14 +1384,6 @@ class AuthRepositoryImpl(
|
||||
onFailure = { LeaveOrganizationResult.Error(error = it) },
|
||||
)
|
||||
|
||||
override suspend fun revokeFromOrganization(
|
||||
organizationId: String,
|
||||
): RevokeFromOrganizationResult =
|
||||
organizationService.revokeFromOrganization(organizationId).fold(
|
||||
onSuccess = { RevokeFromOrganizationResult.Success },
|
||||
onFailure = { RevokeFromOrganizationResult.Error(error = it) },
|
||||
)
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
private suspend fun validatePasswordAgainstPolicy(
|
||||
password: String,
|
||||
@@ -1857,23 +1806,14 @@ class AuthRepositoryImpl(
|
||||
)
|
||||
.map {
|
||||
unlockVault(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = loginResponse.accountKeys
|
||||
?.securityState
|
||||
?.securityState,
|
||||
signingKey = loginResponse.accountKeys
|
||||
?.signatureKeyPair
|
||||
?.wrappedSigningKey,
|
||||
signedPublicKey = loginResponse.accountKeys
|
||||
?.publicKeyEncryptionKeyPair
|
||||
?.signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
privateKey = privateKey,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
|
||||
masterKey = it.masterKey,
|
||||
userKey = key,
|
||||
),
|
||||
securityState = loginResponse.accountKeys?.securityState?.securityState,
|
||||
signingKey = loginResponse.accountKeys?.signatureKeyPair?.wrappedSigningKey,
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
@@ -1894,21 +1834,11 @@ class AuthRepositoryImpl(
|
||||
organizationIdentifier = orgIdentifier,
|
||||
)
|
||||
.map { keyConnectorResponse ->
|
||||
val accountKeys = loginResponse.accountKeys
|
||||
val result = unlockVault(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = keyConnectorResponse.keys.private,
|
||||
securityState = accountKeys
|
||||
?.securityState
|
||||
?.securityState,
|
||||
signingKey = accountKeys
|
||||
?.signatureKeyPair
|
||||
?.wrappedSigningKey,
|
||||
signedPublicKey = accountKeys
|
||||
?.publicKeyEncryptionKeyPair
|
||||
?.signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
privateKey = keyConnectorResponse.keys.private,
|
||||
securityState = loginResponse.accountKeys?.securityState?.securityState,
|
||||
signingKey = loginResponse.accountKeys?.signatureKeyPair?.wrappedSigningKey,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
|
||||
masterKey = keyConnectorResponse.masterKey,
|
||||
userKey = keyConnectorResponse.encryptedUserKey,
|
||||
@@ -1953,30 +1883,27 @@ class AuthRepositoryImpl(
|
||||
// Attempt to unlock the vault with password if possible.
|
||||
val masterPassword = password ?: return null
|
||||
val privateKey = loginResponse.privateKeyOrNull() ?: return null
|
||||
val key = loginResponse.key ?: return null
|
||||
|
||||
val masterPasswordUnlock = loginResponse
|
||||
val initUserCryptoMethod = loginResponse
|
||||
.userDecryptionOptions
|
||||
?.masterPasswordUnlock
|
||||
?: return null
|
||||
val initUserCryptoMethod = InitUserCryptoMethod.MasterPasswordUnlock(
|
||||
password = masterPassword,
|
||||
masterPasswordUnlock = masterPasswordUnlock.toSdkMasterPasswordUnlock(),
|
||||
)
|
||||
?.let { masterPasswordUnlock ->
|
||||
InitUserCryptoMethod.MasterPasswordUnlock(
|
||||
password = masterPassword,
|
||||
masterPasswordUnlock = masterPasswordUnlock.toSdkMasterPasswordUnlock(),
|
||||
)
|
||||
}
|
||||
?: InitUserCryptoMethod.Password(
|
||||
password = masterPassword,
|
||||
userKey = key,
|
||||
)
|
||||
|
||||
return unlockVault(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = loginResponse.accountKeys
|
||||
?.securityState
|
||||
?.securityState,
|
||||
signingKey = loginResponse.accountKeys
|
||||
?.signatureKeyPair
|
||||
?.wrappedSigningKey,
|
||||
signedPublicKey = loginResponse.accountKeys
|
||||
?.publicKeyEncryptionKeyPair
|
||||
?.signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
privateKey = privateKey,
|
||||
securityState = loginResponse.accountKeys?.securityState?.securityState,
|
||||
signingKey = loginResponse.accountKeys?.signatureKeyPair?.wrappedSigningKey,
|
||||
initUserCryptoMethod = initUserCryptoMethod,
|
||||
)
|
||||
}
|
||||
@@ -1984,7 +1911,6 @@ class AuthRepositoryImpl(
|
||||
/**
|
||||
* Attempt to unlock the current user's vault with trusted device specific data.
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
private suspend fun unlockVaultWithTdeOnLoginSuccess(
|
||||
loginResponse: GetTokenResponseJson.Success,
|
||||
profile: AccountJson.Profile,
|
||||
@@ -1997,19 +1923,10 @@ class AuthRepositoryImpl(
|
||||
if (privateKey != null && key != null) {
|
||||
deviceData?.let { model ->
|
||||
return unlockVault(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = loginResponse.accountKeys
|
||||
?.securityState
|
||||
?.securityState,
|
||||
signingKey = loginResponse.accountKeys
|
||||
?.signatureKeyPair
|
||||
?.wrappedSigningKey,
|
||||
signedPublicKey = loginResponse.accountKeys
|
||||
?.publicKeyEncryptionKeyPair
|
||||
?.signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
privateKey = privateKey,
|
||||
securityState = loginResponse.accountKeys?.securityState?.securityState,
|
||||
signingKey = loginResponse.accountKeys?.signatureKeyPair?.wrappedSigningKey,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
|
||||
requestPrivateKey = model.privateKey,
|
||||
method = model
|
||||
@@ -2039,18 +1956,9 @@ class AuthRepositoryImpl(
|
||||
unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
|
||||
options = options,
|
||||
profile = profile,
|
||||
privateKey = accountKeys
|
||||
.publicKeyEncryptionKeyPair
|
||||
.wrappedPrivateKey,
|
||||
securityState = accountKeys
|
||||
.securityState
|
||||
?.securityState,
|
||||
signedPublicKey = accountKeys
|
||||
.publicKeyEncryptionKeyPair
|
||||
.signedPublicKey,
|
||||
signingKey = accountKeys
|
||||
.signatureKeyPair
|
||||
?.wrappedSigningKey,
|
||||
privateKey = accountKeys.publicKeyEncryptionKeyPair.wrappedPrivateKey,
|
||||
securityState = accountKeys.securityState?.securityState,
|
||||
signingKey = accountKeys.signatureKeyPair?.wrappedSigningKey,
|
||||
)
|
||||
}
|
||||
?: loginResponse.privateKey
|
||||
@@ -2060,7 +1968,6 @@ class AuthRepositoryImpl(
|
||||
profile = profile,
|
||||
privateKey = privateKey,
|
||||
securityState = null,
|
||||
signedPublicKey = null,
|
||||
signingKey = null,
|
||||
)
|
||||
}
|
||||
@@ -2076,7 +1983,6 @@ class AuthRepositoryImpl(
|
||||
profile: AccountJson.Profile,
|
||||
privateKey: String,
|
||||
securityState: String?,
|
||||
signedPublicKey: String?,
|
||||
signingKey: String?,
|
||||
): VaultUnlockResult? {
|
||||
var vaultUnlockResult: VaultUnlockResult? = null
|
||||
@@ -2094,13 +2000,10 @@ class AuthRepositoryImpl(
|
||||
// For approved requests the key will always be present.
|
||||
val userKey = requireNotNull(request.key)
|
||||
vaultUnlockResult = unlockVault(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = securityState,
|
||||
signingKey = signingKey,
|
||||
signedPublicKey = signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
privateKey = privateKey,
|
||||
signingKey = signingKey,
|
||||
securityState = securityState,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
|
||||
requestPrivateKey = pendingRequest.requestPrivateKey,
|
||||
method = AuthRequestMethod.UserKey(protectedUserKey = userKey),
|
||||
@@ -2126,13 +2029,10 @@ class AuthRepositoryImpl(
|
||||
}
|
||||
|
||||
vaultUnlockResult = unlockVault(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = securityState,
|
||||
signingKey = signingKey,
|
||||
signedPublicKey = signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
privateKey = privateKey,
|
||||
securityState = securityState,
|
||||
signingKey = signingKey,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.DeviceKey(
|
||||
deviceKey = deviceKey,
|
||||
protectedDevicePrivateKey = encryptedPrivateKey,
|
||||
@@ -2150,16 +2050,20 @@ class AuthRepositoryImpl(
|
||||
* A helper function to unlock the vault for the user associated with the [accountProfile].
|
||||
*/
|
||||
private suspend fun unlockVault(
|
||||
accountCryptographicState: WrappedAccountCryptographicState,
|
||||
accountProfile: AccountJson.Profile,
|
||||
privateKey: String,
|
||||
securityState: String?,
|
||||
signingKey: String?,
|
||||
initUserCryptoMethod: InitUserCryptoMethod,
|
||||
): VaultUnlockResult {
|
||||
val userId = accountProfile.userId
|
||||
return vaultRepository.unlockVault(
|
||||
accountCryptographicState = accountCryptographicState,
|
||||
userId = userId,
|
||||
email = accountProfile.email,
|
||||
kdf = accountProfile.toSdkParams(),
|
||||
privateKey = privateKey,
|
||||
signingKey = signingKey,
|
||||
securityState = securityState,
|
||||
initUserCryptoMethod = initUserCryptoMethod,
|
||||
// The value for the organization keys here will typically be null. We can separately
|
||||
// unlock the vault for organization data after receiving the sync response if this
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.di
|
||||
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.core.data.manager.toast.ToastManager
|
||||
import com.bitwarden.data.datasource.disk.ConfigDiskSource
|
||||
import com.bitwarden.network.service.AccountsService
|
||||
import com.bitwarden.network.service.DevicesService
|
||||
@@ -20,7 +19,6 @@ import com.x8bit.bitwarden.data.auth.manager.UserStateManagerImpl
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.LogsManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
@@ -62,7 +60,6 @@ object AuthRepositoryModule {
|
||||
environmentRepository: EnvironmentRepository,
|
||||
settingsRepository: SettingsRepository,
|
||||
vaultRepository: VaultRepository,
|
||||
biometricsEncryptionManager: BiometricsEncryptionManager,
|
||||
keyConnectorManager: KeyConnectorManager,
|
||||
authRequestManager: AuthRequestManager,
|
||||
trustedDeviceManager: TrustedDeviceManager,
|
||||
@@ -72,7 +69,6 @@ object AuthRepositoryModule {
|
||||
logsManager: LogsManager,
|
||||
userStateManager: UserStateManager,
|
||||
kdfManager: KdfManager,
|
||||
toastManager: ToastManager,
|
||||
): AuthRepository = AuthRepositoryImpl(
|
||||
clock = clock,
|
||||
accountsService = accountsService,
|
||||
@@ -89,7 +85,6 @@ object AuthRepositoryModule {
|
||||
environmentRepository = environmentRepository,
|
||||
settingsRepository = settingsRepository,
|
||||
vaultRepository = vaultRepository,
|
||||
biometricsEncryptionManager = biometricsEncryptionManager,
|
||||
keyConnectorManager = keyConnectorManager,
|
||||
authRequestManager = authRequestManager,
|
||||
trustedDeviceManager = trustedDeviceManager,
|
||||
@@ -99,7 +94,6 @@ object AuthRepositoryModule {
|
||||
logsManager = logsManager,
|
||||
userStateManager = userStateManager,
|
||||
kdfManager = kdfManager,
|
||||
toastManager = toastManager,
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.model
|
||||
|
||||
/**
|
||||
* Models result of leaving an organization.
|
||||
* Models result of deleting an account.
|
||||
*/
|
||||
sealed class LeaveOrganizationResult {
|
||||
/**
|
||||
|
||||
@@ -14,16 +14,14 @@ import com.bitwarden.network.model.OrganizationType
|
||||
* @property keyConnectorUrl The key connector domain (if applicable).
|
||||
* @property userIsClaimedByOrganization Indicates that the user is claimed by the organization.
|
||||
* @property limitItemDeletion Indicates that the organization limits item deletion.
|
||||
* @property shouldUseEvents Indicates if the organization uses tracking events.
|
||||
*/
|
||||
data class Organization(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val name: String?,
|
||||
val shouldManageResetPassword: Boolean,
|
||||
val shouldUseKeyConnector: Boolean,
|
||||
val role: OrganizationType,
|
||||
val keyConnectorUrl: String?,
|
||||
val userIsClaimedByOrganization: Boolean,
|
||||
val limitItemDeletion: Boolean,
|
||||
val shouldUseEvents: Boolean,
|
||||
val limitItemDeletion: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.model
|
||||
|
||||
/**
|
||||
* Models result of leaving an organization.
|
||||
*/
|
||||
sealed class RevokeFromOrganizationResult {
|
||||
/**
|
||||
* Revoke from organization succeeded.
|
||||
*/
|
||||
data object Success : RevokeFromOrganizationResult()
|
||||
|
||||
/**
|
||||
* There was an error revoking from the organization.
|
||||
*/
|
||||
data class Error(
|
||||
val error: Throwable?,
|
||||
) : RevokeFromOrganizationResult()
|
||||
}
|
||||
@@ -9,7 +9,7 @@ data class UserAccountTokens(
|
||||
val refreshToken: String?,
|
||||
) {
|
||||
/**
|
||||
* Returns `true` if the user is logged in, `false` otherwise.
|
||||
* Returns `true` if the user is logged in, `false otherwise.
|
||||
*/
|
||||
val isLoggedIn: Boolean get() = accessToken != null
|
||||
}
|
||||
|
||||
@@ -5,11 +5,7 @@ import android.net.Uri
|
||||
import androidx.browser.auth.AuthTabIntent
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
|
||||
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
|
||||
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
|
||||
private const val APP_LINK_SCHEME: String = "https"
|
||||
private const val DEEPLINK_SCHEME: String = "bitwarden"
|
||||
private const val CALLBACK: String = "duo-callback"
|
||||
private const val DUO_HOST: String = "duo-callback"
|
||||
|
||||
/**
|
||||
* Retrieves a [DuoCallbackTokenResult] from an Intent. There are three possible cases.
|
||||
@@ -22,28 +18,11 @@ private const val CALLBACK: String = "duo-callback"
|
||||
* - [DuoCallbackTokenResult.Success]: Intent is the Duo callback, and it has a token.
|
||||
*/
|
||||
fun Intent.getDuoCallbackTokenResult(): DuoCallbackTokenResult? {
|
||||
if (action != Intent.ACTION_VIEW) return null
|
||||
val localData = data ?: return null
|
||||
return when (localData.scheme) {
|
||||
DEEPLINK_SCHEME -> {
|
||||
if (localData.host == CALLBACK) {
|
||||
localData.getDuoCallbackTokenResult()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
APP_LINK_SCHEME -> {
|
||||
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
|
||||
localData.path == "/$CALLBACK"
|
||||
) {
|
||||
localData.getDuoCallbackTokenResult()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
else -> null
|
||||
val localData = data
|
||||
return if (action == Intent.ACTION_VIEW && localData != null && localData.host == DUO_HOST) {
|
||||
localData.getDuoCallbackTokenResult()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,20 +4,14 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import androidx.browser.auth.AuthTabIntent
|
||||
import androidx.core.net.toUri
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.net.URLEncoder
|
||||
import java.security.MessageDigest
|
||||
import java.util.Base64
|
||||
|
||||
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
|
||||
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
|
||||
private const val APP_LINK_SCHEME: String = "https"
|
||||
private const val DEEPLINK_SCHEME: String = "bitwarden"
|
||||
private const val CALLBACK: String = "sso-callback"
|
||||
|
||||
const val SSO_URI: String = "bitwarden://$CALLBACK"
|
||||
private const val SSO_HOST: String = "sso-callback"
|
||||
const val SSO_URI: String = "bitwarden://$SSO_HOST"
|
||||
|
||||
/**
|
||||
* Generates a URI for the SSO custom tab.
|
||||
@@ -34,7 +28,7 @@ fun generateUriForSso(
|
||||
token: String,
|
||||
state: String,
|
||||
codeVerifier: String,
|
||||
): Uri {
|
||||
): String {
|
||||
val redirectUri = URLEncoder.encode(SSO_URI, "UTF-8")
|
||||
val encodedOrganizationIdentifier = URLEncoder.encode(organizationIdentifier, "UTF-8")
|
||||
val encodedToken = URLEncoder.encode(token, "UTF-8")
|
||||
@@ -45,7 +39,7 @@ fun generateUriForSso(
|
||||
.digest(codeVerifier.toByteArray()),
|
||||
)
|
||||
|
||||
val uri = "$identityBaseUrl/connect/authorize" +
|
||||
return "$identityBaseUrl/connect/authorize" +
|
||||
"?client_id=mobile" +
|
||||
"&redirect_uri=$redirectUri" +
|
||||
"&response_type=code" +
|
||||
@@ -56,7 +50,6 @@ fun generateUriForSso(
|
||||
"&response_mode=query" +
|
||||
"&domain_hint=$encodedOrganizationIdentifier" +
|
||||
"&ssoToken=$encodedToken"
|
||||
return uri.toUri()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,28 +62,11 @@ fun generateUriForSso(
|
||||
* - [SsoCallbackResult.Success]: Intent is the SSO callback with required data.
|
||||
*/
|
||||
fun Intent.getSsoCallbackResult(): SsoCallbackResult? {
|
||||
if (action != Intent.ACTION_VIEW) return null
|
||||
val localData = data ?: return null
|
||||
return when (localData.scheme) {
|
||||
DEEPLINK_SCHEME -> {
|
||||
if (localData.host == CALLBACK) {
|
||||
localData.getSsoCallbackResult()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
APP_LINK_SCHEME -> {
|
||||
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
|
||||
localData.path == "/$CALLBACK"
|
||||
) {
|
||||
localData.getSsoCallbackResult()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
else -> null
|
||||
val localData = data
|
||||
return if (action == Intent.ACTION_VIEW && localData?.host == SSO_HOST) {
|
||||
localData.getSsoCallbackResult()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,30 +13,26 @@ private val JSON = Json {
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the given [SyncResponseJson.Profile.Organization] to an [Organization] or `null` if the
|
||||
* [SyncResponseJson.Profile.Organization.name] is not present.
|
||||
* Maps the given [SyncResponseJson.Profile.Organization] to an [Organization].
|
||||
*/
|
||||
fun SyncResponseJson.Profile.Organization.toOrganization(): Organization? =
|
||||
this.name?.let {
|
||||
Organization(
|
||||
id = this.id,
|
||||
name = it,
|
||||
shouldUseKeyConnector = this.shouldUseKeyConnector,
|
||||
role = this.type,
|
||||
shouldManageResetPassword = this.permissions.shouldManageResetPassword,
|
||||
keyConnectorUrl = this.keyConnectorUrl,
|
||||
userIsClaimedByOrganization = this.userIsClaimedByOrganization,
|
||||
limitItemDeletion = this.limitItemDeletion,
|
||||
shouldUseEvents = this.shouldUseEvents,
|
||||
)
|
||||
}
|
||||
fun SyncResponseJson.Profile.Organization.toOrganization(): Organization =
|
||||
Organization(
|
||||
id = this.id,
|
||||
name = this.name,
|
||||
shouldUseKeyConnector = this.shouldUseKeyConnector,
|
||||
role = this.type,
|
||||
shouldManageResetPassword = this.permissions.shouldManageResetPassword,
|
||||
keyConnectorUrl = this.keyConnectorUrl,
|
||||
userIsClaimedByOrganization = this.userIsClaimedByOrganization,
|
||||
limitItemDeletion = this.limitItemDeletion,
|
||||
)
|
||||
|
||||
/**
|
||||
* Maps the given list of [SyncResponseJson.Profile.Organization] to a list of
|
||||
* [Organization]s.
|
||||
*/
|
||||
fun List<SyncResponseJson.Profile.Organization>.toOrganizations(): List<Organization> =
|
||||
this.mapNotNull { it.toOrganization() }
|
||||
this.map { it.toOrganization() }
|
||||
|
||||
/**
|
||||
* Convert the JSON data of the [SyncResponseJson.Policy] object into [PolicyInformation] data.
|
||||
|
||||
@@ -11,13 +11,8 @@ import kotlinx.serialization.json.put
|
||||
import java.net.URLEncoder
|
||||
import java.util.Base64
|
||||
|
||||
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
|
||||
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
|
||||
private const val APP_LINK_SCHEME: String = "https"
|
||||
private const val DEEPLINK_SCHEME: String = "bitwarden"
|
||||
private const val CALLBACK: String = "webauthn-callback"
|
||||
|
||||
private const val CALLBACK_URI = "bitwarden://$CALLBACK"
|
||||
private const val WEB_AUTH_HOST: String = "webauthn-callback"
|
||||
private const val CALLBACK_URI = "bitwarden://$WEB_AUTH_HOST"
|
||||
|
||||
/**
|
||||
* Retrieves an [WebAuthResult] from an [Intent]. There are three possible cases.
|
||||
@@ -27,28 +22,14 @@ private const val CALLBACK_URI = "bitwarden://$CALLBACK"
|
||||
* - [WebAuthResult.Failure]: Intent is the web auth key callback with incorrect data.
|
||||
*/
|
||||
fun Intent.getWebAuthResultOrNull(): WebAuthResult? {
|
||||
if (action != Intent.ACTION_VIEW) return null
|
||||
val localData = data ?: return null
|
||||
return when (localData.scheme) {
|
||||
DEEPLINK_SCHEME -> {
|
||||
if (localData.host == CALLBACK) {
|
||||
localData.getWebAuthResult()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
APP_LINK_SCHEME -> {
|
||||
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
|
||||
localData.path == "/$CALLBACK"
|
||||
) {
|
||||
localData.getWebAuthResult()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
else -> null
|
||||
val localData = data
|
||||
return if (action == Intent.ACTION_VIEW &&
|
||||
localData != null &&
|
||||
localData.host == WEB_AUTH_HOST
|
||||
) {
|
||||
localData.getWebAuthResult()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.auth.util
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import android.net.Uri
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
|
||||
|
||||
/**
|
||||
@@ -14,7 +14,7 @@ fun Intent.getCompleteRegistrationDataIntentOrNull(): CompleteRegistrationData?
|
||||
newValue = "/",
|
||||
ignoreCase = true,
|
||||
)
|
||||
val uri = runCatching { sanitizedUriString.toUri() }.getOrNull() ?: return null
|
||||
val uri = runCatching { Uri.parse(sanitizedUriString) }.getOrNull() ?: return null
|
||||
uri.host ?: return null
|
||||
if (uri.path != "/finish-signup") return null
|
||||
val email = uri.getQueryParameter("email") ?: return null
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.x8bit.bitwarden.data.autofill.accessibility.util
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import java.net.URISyntaxException
|
||||
|
||||
@@ -11,7 +10,7 @@ import java.net.URISyntaxException
|
||||
@OmitFromCoverage
|
||||
fun String.toUriOrNull(): Uri? =
|
||||
try {
|
||||
this.toUri()
|
||||
} catch (_: URISyntaxException) {
|
||||
Uri.parse(this)
|
||||
} catch (e: URISyntaxException) {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -68,8 +68,6 @@ class AutofillCipherProviderImpl(
|
||||
it.type is CipherListViewType.Card &&
|
||||
// Must not be deleted.
|
||||
it.deletedDate == null &&
|
||||
// Must not be archived.
|
||||
it.archivedDate == null &&
|
||||
// Must not require a reprompt.
|
||||
it.reprompt == CipherRepromptType.NONE &&
|
||||
// Must not be restricted by organization.
|
||||
@@ -108,8 +106,6 @@ class AutofillCipherProviderImpl(
|
||||
it.type is CipherListViewType.Login &&
|
||||
// Must not be deleted.
|
||||
it.deletedDate == null &&
|
||||
// Must not be archived.
|
||||
it.archivedDate == null &&
|
||||
// Must not require a reprompt.
|
||||
it.reprompt == CipherRepromptType.NONE
|
||||
}
|
||||
|
||||
@@ -7,19 +7,16 @@ import com.bitwarden.vault.CopyableCipherFields
|
||||
import com.bitwarden.vault.LoginListView
|
||||
|
||||
/**
|
||||
* Returns true when the cipher is not archived, not deleted and contains at least one FIDO 2
|
||||
* credential.
|
||||
* Returns true when the cipher is not deleted and contains at least one FIDO 2 credential.
|
||||
*/
|
||||
val CipherListView.isActiveWithFido2Credentials: Boolean
|
||||
get() = archivedDate == null && deletedDate == null && login?.hasFido2 ?: false
|
||||
get() = deletedDate == null && login?.hasFido2 ?: false
|
||||
|
||||
/**
|
||||
* Returns true when the cipher type is not archived, not deleted and contains a copyable password.
|
||||
* Returns true when the cipher type is not deleted and contains a copyable password.
|
||||
*/
|
||||
val CipherListView.isActiveWithCopyablePassword: Boolean
|
||||
get() = archivedDate == null &&
|
||||
deletedDate == null &&
|
||||
copyableFields.contains(CopyableCipherFields.LOGIN_PASSWORD)
|
||||
get() = deletedDate == null && copyableFields.contains(CopyableCipherFields.LOGIN_PASSWORD)
|
||||
|
||||
/**
|
||||
* Returns the [LoginListView] if the cipher is of type [CipherListViewType.Login], otherwise null.
|
||||
|
||||
@@ -48,17 +48,13 @@ fun CipherView.toAutofillCipherProvider(): AutofillCipherProvider =
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the cipher is not archived, not deleted and contains at least one FIDO 2
|
||||
* credential.
|
||||
* Returns true when the cipher is not deleted and contains at least one FIDO 2 credential.
|
||||
*/
|
||||
val CipherView.isActiveWithFido2Credentials: Boolean
|
||||
get() = archivedDate == null &&
|
||||
deletedDate == null &&
|
||||
!(login?.fido2Credentials.isNullOrEmpty())
|
||||
get() = deletedDate == null && !(login?.fido2Credentials.isNullOrEmpty())
|
||||
|
||||
/**
|
||||
* Returns true when the cipher is not archived, not deleted and contains at least one Password
|
||||
* credential.
|
||||
* Returns true when the cipher is not deleted and contains at least one Pasword credential.
|
||||
*/
|
||||
val CipherView.isActiveWithPasswordCredentials: Boolean
|
||||
get() = archivedDate == null && deletedDate == null && !(login?.password.isNullOrEmpty())
|
||||
get() = deletedDate == null && !(login?.password.isNullOrEmpty())
|
||||
|
||||
@@ -11,10 +11,10 @@ import com.bitwarden.fido.Fido2CredentialAutofillView
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.vault.CipherListView
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.autofill.util.login
|
||||
import com.x8bit.bitwarden.data.credentials.manager.CredentialManagerPendingIntentManager
|
||||
import com.x8bit.bitwarden.data.credentials.util.setBiometricPromptDataIfSupported
|
||||
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
|
||||
|
||||
/**
|
||||
* Primary implementation of [CredentialEntryBuilder].
|
||||
@@ -22,7 +22,7 @@ import com.x8bit.bitwarden.data.credentials.util.setBiometricPromptDataIfSupport
|
||||
class CredentialEntryBuilderImpl(
|
||||
private val context: Context,
|
||||
private val pendingIntentManager: CredentialManagerPendingIntentManager,
|
||||
private val authRepository: AuthRepository,
|
||||
private val biometricsEncryptionManager: BiometricsEncryptionManager,
|
||||
) : CredentialEntryBuilder {
|
||||
|
||||
override fun buildPublicKeyCredentialEntries(
|
||||
@@ -82,7 +82,7 @@ class CredentialEntryBuilderImpl(
|
||||
.also { builder ->
|
||||
if (!isUserVerified) {
|
||||
builder.setBiometricPromptDataIfSupported(
|
||||
cipher = authRepository.getOrCreateCipher(userId),
|
||||
cipher = biometricsEncryptionManager.getOrCreateCipher(userId),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,8 @@ class CredentialEntryBuilderImpl(
|
||||
.apply {
|
||||
if (!isUserVerified) {
|
||||
setBiometricPromptDataIfSupported(
|
||||
cipher = authRepository.getOrCreateCipher(userId),
|
||||
cipher = biometricsEncryptionManager
|
||||
.getOrCreateCipher(userId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@ import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
|
||||
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManagerImpl
|
||||
import com.x8bit.bitwarden.data.credentials.manager.CredentialManagerPendingIntentManager
|
||||
import com.x8bit.bitwarden.data.credentials.manager.CredentialManagerPendingIntentManagerImpl
|
||||
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
|
||||
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManagerImpl
|
||||
import com.x8bit.bitwarden.data.credentials.manager.OriginManager
|
||||
import com.x8bit.bitwarden.data.credentials.manager.OriginManagerImpl
|
||||
import com.x8bit.bitwarden.data.credentials.parser.RelyingPartyParser
|
||||
@@ -27,6 +25,7 @@ import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepositoryIm
|
||||
import com.x8bit.bitwarden.data.credentials.sanitizer.PasskeyAttestationOptionsSanitizer
|
||||
import com.x8bit.bitwarden.data.credentials.sanitizer.PasskeyAttestationOptionsSanitizerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.AssetManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
@@ -55,6 +54,7 @@ object CredentialProviderModule {
|
||||
bitwardenCredentialManager: BitwardenCredentialManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
pendingIntentManager: CredentialManagerPendingIntentManager,
|
||||
biometricsEncryptionManager: BiometricsEncryptionManager,
|
||||
clock: Clock,
|
||||
): CredentialProviderProcessor =
|
||||
CredentialProviderProcessorImpl(
|
||||
@@ -63,6 +63,7 @@ object CredentialProviderModule {
|
||||
bitwardenCredentialManager = bitwardenCredentialManager,
|
||||
pendingIntentManager = pendingIntentManager,
|
||||
clock = clock,
|
||||
biometricsEncryptionManager = biometricsEncryptionManager,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
|
||||
@@ -107,11 +108,11 @@ object CredentialProviderModule {
|
||||
fun provideCredentialEntryBuilder(
|
||||
@ApplicationContext context: Context,
|
||||
pendingIntentManager: CredentialManagerPendingIntentManager,
|
||||
authRepository: AuthRepository,
|
||||
biometricsEncryptionManager: BiometricsEncryptionManager,
|
||||
): CredentialEntryBuilder = CredentialEntryBuilderImpl(
|
||||
context = context,
|
||||
pendingIntentManager = pendingIntentManager,
|
||||
authRepository = authRepository,
|
||||
biometricsEncryptionManager = biometricsEncryptionManager,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@@ -147,9 +148,4 @@ object CredentialProviderModule {
|
||||
@Singleton
|
||||
fun providePasskeyAttestationOptionsSanitizer(): PasskeyAttestationOptionsSanitizer =
|
||||
PasskeyAttestationOptionsSanitizerImpl
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCredentialProviderRequestManager(): CredentialProviderRequestManager =
|
||||
CredentialProviderRequestManagerImpl()
|
||||
}
|
||||
|
||||
@@ -50,8 +50,6 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import timber.log.Timber
|
||||
|
||||
private const val DAL_ROUTE = ".well-known/assetlinks.json"
|
||||
|
||||
/**
|
||||
* Primary implementation of [BitwardenCredentialManager].
|
||||
*/
|
||||
@@ -125,7 +123,7 @@ class BitwardenCredentialManagerImpl(
|
||||
.getSignatureFingerprintAsHexString()
|
||||
.orEmpty(),
|
||||
host = hostUrl,
|
||||
assetLinkUrl = hostUrl.toDigitalAssetLinkUrl(),
|
||||
assetLinkUrl = hostUrl,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -260,7 +258,6 @@ class BitwardenCredentialManagerImpl(
|
||||
userId = userId,
|
||||
fido2CredentialStore = fido2CredentialStore,
|
||||
relyingPartyId = relyingPartyId,
|
||||
userHandle = null,
|
||||
)
|
||||
.fold(
|
||||
onSuccess = { it },
|
||||
@@ -318,7 +315,7 @@ class BitwardenCredentialManagerImpl(
|
||||
packageName = callingAppInfo.packageName,
|
||||
sha256CertFingerprint = signatureFingerprint,
|
||||
host = host,
|
||||
assetLinkUrl = host.toDigitalAssetLinkUrl(),
|
||||
assetLinkUrl = host,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -430,13 +427,6 @@ class BitwardenCredentialManagerImpl(
|
||||
?.relyingParty
|
||||
?.id
|
||||
?.prefixHttpsIfNecessaryOrNull()
|
||||
|
||||
private fun String.toDigitalAssetLinkUrl(): String =
|
||||
when {
|
||||
this.endsWith(DAL_ROUTE) -> this
|
||||
this.endsWith("/") -> "$this$DAL_ROUTE"
|
||||
else -> "$this/$DAL_ROUTE"
|
||||
}
|
||||
}
|
||||
|
||||
private const val MAX_AUTHENTICATION_ATTEMPTS = 5
|
||||
|
||||
@@ -23,7 +23,6 @@ class CredentialManagerPendingIntentManagerImpl(
|
||||
): PendingIntent {
|
||||
val intent = Intent(CREATE_PASSKEY_ACTION)
|
||||
.setPackage(context.packageName)
|
||||
.setClass(context, CREDENTIAL_ACTIVITY_CLASS)
|
||||
.putExtra(EXTRA_KEY_USER_ID, userId)
|
||||
|
||||
return PendingIntent.getActivity(
|
||||
@@ -45,7 +44,6 @@ class CredentialManagerPendingIntentManagerImpl(
|
||||
): PendingIntent {
|
||||
val intent = Intent(GET_PASSKEY_ACTION)
|
||||
.setPackage(context.packageName)
|
||||
.setClass(context, CREDENTIAL_ACTIVITY_CLASS)
|
||||
.putExtra(EXTRA_KEY_USER_ID, userId)
|
||||
.putExtra(EXTRA_KEY_CREDENTIAL_ID, credentialId)
|
||||
.putExtra(EXTRA_KEY_CIPHER_ID, cipherId)
|
||||
@@ -67,7 +65,6 @@ class CredentialManagerPendingIntentManagerImpl(
|
||||
): PendingIntent {
|
||||
val intent = Intent(UNLOCK_ACCOUNT_ACTION)
|
||||
.setPackage(context.packageName)
|
||||
.setClass(context, CREDENTIAL_ACTIVITY_CLASS)
|
||||
.putExtra(EXTRA_KEY_USER_ID, userId)
|
||||
|
||||
return PendingIntent.getActivity(
|
||||
@@ -86,7 +83,6 @@ class CredentialManagerPendingIntentManagerImpl(
|
||||
): PendingIntent {
|
||||
val intent = Intent(CREATE_PASSWORD_ACTION)
|
||||
.setPackage(context.packageName)
|
||||
.setClass(context, CREDENTIAL_ACTIVITY_CLASS)
|
||||
.putExtra(EXTRA_KEY_USER_ID, userId)
|
||||
|
||||
return PendingIntent.getActivity(
|
||||
@@ -107,7 +103,6 @@ class CredentialManagerPendingIntentManagerImpl(
|
||||
): PendingIntent {
|
||||
val intent = Intent(GET_PASSWORD_ACTION)
|
||||
.setPackage(context.packageName)
|
||||
.setClass(context, CREDENTIAL_ACTIVITY_CLASS)
|
||||
.putExtra(EXTRA_KEY_USER_ID, userId)
|
||||
.putExtra(EXTRA_KEY_CIPHER_ID, cipherId)
|
||||
.putExtra(EXTRA_KEY_UV_PERFORMED_DURING_UNLOCK, isUserVerified)
|
||||
@@ -121,7 +116,6 @@ class CredentialManagerPendingIntentManagerImpl(
|
||||
}
|
||||
}
|
||||
|
||||
private val CREDENTIAL_ACTIVITY_CLASS = com.x8bit.bitwarden.CredentialProviderActivity::class.java
|
||||
private const val CREATE_PASSKEY_ACTION = "com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSKEY"
|
||||
private const val UNLOCK_ACCOUNT_ACTION = "com.x8bit.bitwarden.credentials.ACTION_UNLOCK_ACCOUNT"
|
||||
private const val GET_PASSKEY_ACTION = "com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY"
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package com.x8bit.bitwarden.data.credentials.manager
|
||||
|
||||
import com.x8bit.bitwarden.CredentialProviderActivity
|
||||
import com.x8bit.bitwarden.MainActivity
|
||||
import com.x8bit.bitwarden.MainViewModel
|
||||
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
|
||||
|
||||
/**
|
||||
* Manages pending credential provider requests, relaying them from [CredentialProviderActivity]
|
||||
* to [MainActivity] via a pull-based pattern.
|
||||
*
|
||||
* This approach ensures credential data is never passed through intent extras to
|
||||
* exported activities. [CredentialProviderActivity] sets the request, then [MainViewModel]
|
||||
* retrieves it once when handling the incoming intent.
|
||||
*/
|
||||
interface CredentialProviderRequestManager {
|
||||
/**
|
||||
* Set a pending credential request.
|
||||
*/
|
||||
fun setPendingCredentialRequest(request: CredentialProviderRequest)
|
||||
|
||||
/**
|
||||
* Get and clear the pending credential request. Returns null if no request is pending.
|
||||
*/
|
||||
fun getPendingCredentialRequest(): CredentialProviderRequest?
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package com.x8bit.bitwarden.data.credentials.manager
|
||||
|
||||
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Primary implementation of [CredentialProviderRequestManager].
|
||||
*
|
||||
* Uses an [AtomicReference] for thread-safe get-and-clear semantics, ensuring
|
||||
* the pending request is only processed once.
|
||||
*/
|
||||
@Singleton
|
||||
class CredentialProviderRequestManagerImpl : CredentialProviderRequestManager {
|
||||
|
||||
private val pendingRequest = AtomicReference<CredentialProviderRequest?>(null)
|
||||
|
||||
override fun setPendingCredentialRequest(request: CredentialProviderRequest) {
|
||||
pendingRequest.set(request)
|
||||
}
|
||||
|
||||
override fun getPendingCredentialRequest(): CredentialProviderRequest? {
|
||||
return pendingRequest.getAndSet(null)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.credentials.manager
|
||||
import androidx.credentials.provider.CallingAppInfo
|
||||
import com.bitwarden.network.service.DigitalAssetLinkService
|
||||
import com.bitwarden.ui.platform.base.util.prefixHttpsIfNecessary
|
||||
import com.bitwarden.ui.platform.base.util.prefixWwwIfNecessary
|
||||
import com.x8bit.bitwarden.data.credentials.model.ValidateOriginResult
|
||||
import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepository
|
||||
import com.x8bit.bitwarden.data.platform.manager.AssetManager
|
||||
@@ -40,7 +41,13 @@ class OriginManagerImpl(
|
||||
): ValidateOriginResult {
|
||||
return digitalAssetLinkService
|
||||
.checkDigitalAssetLinksRelations(
|
||||
sourceWebSite = relyingPartyId.prefixHttpsIfNecessary(),
|
||||
sourceWebSite = relyingPartyId
|
||||
// The DAL API does not allow redirects, so we add `www.` to prevent redirects
|
||||
// when it is absent from the `relyingPartyId`. This ensures that relying
|
||||
// parties storing their `assetlinks.json` at the `www.` subdomain do not fail
|
||||
// verification checks.
|
||||
.prefixWwwIfNecessary()
|
||||
.prefixHttpsIfNecessary(),
|
||||
targetPackageName = callingAppInfo.packageName,
|
||||
targetCertificateFingerprint = callingAppInfo
|
||||
.getSignatureFingerprintAsHexString()
|
||||
@@ -49,7 +56,6 @@ class OriginManagerImpl(
|
||||
)
|
||||
.fold(
|
||||
onSuccess = {
|
||||
Timber.d("Digital asset link validation result: linked = ${it.linked}")
|
||||
if (it.linked) {
|
||||
ValidateOriginResult.Success(null)
|
||||
} else {
|
||||
@@ -57,7 +63,6 @@ class OriginManagerImpl(
|
||||
}
|
||||
},
|
||||
onFailure = {
|
||||
Timber.e("Failed to validate origin for calling app")
|
||||
ValidateOriginResult.Error.AssetLinkNotFound
|
||||
},
|
||||
)
|
||||
@@ -107,7 +112,7 @@ class OriginManagerImpl(
|
||||
.fold(
|
||||
onSuccess = { it },
|
||||
onFailure = {
|
||||
Timber.e(it, "Failed to validate calling app is privileged.")
|
||||
Timber.e(it, "Failed to validate privileged app: ${callingAppInfo.packageName}")
|
||||
ValidateOriginResult.Error.Unknown
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package com.x8bit.bitwarden.data.credentials.manager.model
|
||||
|
||||
import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest
|
||||
import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionRequest
|
||||
import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
|
||||
import com.x8bit.bitwarden.data.credentials.model.ProviderGetPasswordCredentialRequest
|
||||
|
||||
/**
|
||||
* Represents a pending credential provider request to be processed by MainActivity.
|
||||
*/
|
||||
sealed class CredentialProviderRequest {
|
||||
/**
|
||||
* Request to create a new FIDO2 passkey credential.
|
||||
*/
|
||||
data class CreateCredential(
|
||||
val request: CreateCredentialRequest,
|
||||
) : CredentialProviderRequest()
|
||||
|
||||
/**
|
||||
* Request to assert (authenticate with) an existing FIDO2 passkey.
|
||||
*/
|
||||
data class Fido2Assertion(
|
||||
val request: Fido2CredentialAssertionRequest,
|
||||
) : CredentialProviderRequest()
|
||||
|
||||
/**
|
||||
* Request to retrieve a password credential.
|
||||
*/
|
||||
data class GetPassword(
|
||||
val request: ProviderGetPasswordCredentialRequest,
|
||||
) : CredentialProviderRequest()
|
||||
|
||||
/**
|
||||
* Request to get available credentials (BeginGetCredential flow).
|
||||
*/
|
||||
data class GetCredentials(
|
||||
val request: GetCredentialsRequest,
|
||||
) : CredentialProviderRequest()
|
||||
}
|
||||
@@ -34,9 +34,9 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
|
||||
import com.x8bit.bitwarden.data.credentials.manager.CredentialManagerPendingIntentManager
|
||||
import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
|
||||
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.time.Clock
|
||||
import javax.crypto.Cipher
|
||||
|
||||
@@ -52,6 +52,7 @@ class CredentialProviderProcessorImpl(
|
||||
private val bitwardenCredentialManager: BitwardenCredentialManager,
|
||||
private val pendingIntentManager: CredentialManagerPendingIntentManager,
|
||||
private val clock: Clock,
|
||||
private val biometricsEncryptionManager: BiometricsEncryptionManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
) : CredentialProviderProcessor {
|
||||
|
||||
@@ -62,10 +63,8 @@ class CredentialProviderProcessorImpl(
|
||||
cancellationSignal: CancellationSignal,
|
||||
callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,
|
||||
) {
|
||||
Timber.d("Create credential request received.")
|
||||
val userId = authRepository.activeUserId
|
||||
if (userId == null) {
|
||||
Timber.w("No active user. Cannot create credential.")
|
||||
callback.onError(CreateCredentialUnknownException("Active user is required."))
|
||||
return
|
||||
}
|
||||
@@ -73,16 +72,12 @@ class CredentialProviderProcessorImpl(
|
||||
val createCredentialJob = ioScope.launch {
|
||||
(handleCreatePasskeyQuery(request) ?: handleCreatePasswordQuery(request))
|
||||
?.let { callback.onResult(it) }
|
||||
?: run {
|
||||
Timber.w("Unknown create credential request.")
|
||||
callback.onError(CreateCredentialUnknownException())
|
||||
}
|
||||
?: callback.onError(CreateCredentialUnknownException())
|
||||
}
|
||||
cancellationSignal.setOnCancelListener {
|
||||
if (createCredentialJob.isActive) {
|
||||
createCredentialJob.cancel()
|
||||
}
|
||||
Timber.d("Create credential request cancelled by system.")
|
||||
callback.onError(CreateCredentialCancellationException())
|
||||
}
|
||||
}
|
||||
@@ -92,18 +87,15 @@ class CredentialProviderProcessorImpl(
|
||||
cancellationSignal: CancellationSignal,
|
||||
callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,
|
||||
) {
|
||||
Timber.d("Get credential request received.")
|
||||
// If the user is not logged in, return an error.
|
||||
val userState = authRepository.userStateFlow.value
|
||||
if (userState == null) {
|
||||
Timber.w("No active user. Cannot get credentials.")
|
||||
callback.onError(GetCredentialUnknownException("Active user is required."))
|
||||
return
|
||||
}
|
||||
|
||||
// Return an unlock action if the current account is locked.
|
||||
if (!userState.activeAccount.isVaultUnlocked) {
|
||||
Timber.d("Vault is locked. Requesting unlock.")
|
||||
val authenticationAction = AuthenticationAction(
|
||||
title = context.getString(BitwardenString.unlock),
|
||||
pendingIntent = pendingIntentManager.createFido2UnlockPendingIntent(
|
||||
@@ -128,17 +120,10 @@ class CredentialProviderProcessorImpl(
|
||||
BeginGetCredentialRequest.asBundle(request),
|
||||
),
|
||||
)
|
||||
.onSuccess {
|
||||
Timber.d("Credentials retrieved.")
|
||||
callback.onResult(BeginGetCredentialResponse(credentialEntries = it))
|
||||
}
|
||||
.onFailure {
|
||||
Timber.w("Error getting credentials.")
|
||||
callback.onError(GetCredentialUnknownException(it.message))
|
||||
}
|
||||
.onSuccess { callback.onResult(BeginGetCredentialResponse(credentialEntries = it)) }
|
||||
.onFailure { callback.onError(GetCredentialUnknownException(it.message)) }
|
||||
}
|
||||
cancellationSignal.setOnCancelListener {
|
||||
Timber.d("Get credential request cancelled by system.")
|
||||
callback.onError(GetCredentialCancellationException())
|
||||
getCredentialJob.cancel()
|
||||
}
|
||||
@@ -150,7 +135,6 @@ class CredentialProviderProcessorImpl(
|
||||
callback: OutcomeReceiver<Void?, ClearCredentialException>,
|
||||
) {
|
||||
// no-op: RFU
|
||||
Timber.w("Unsupported clear credential state request received.")
|
||||
callback.onError(ClearCredentialUnsupportedException())
|
||||
}
|
||||
|
||||
@@ -201,7 +185,7 @@ class CredentialProviderProcessorImpl(
|
||||
.setAutoSelectAllowed(true)
|
||||
|
||||
if (isVaultUnlocked) {
|
||||
authRepository
|
||||
biometricsEncryptionManager
|
||||
.getOrCreateCipher(userId)
|
||||
?.let { entryBuilder.setBiometricPromptDataIfSupported(cipher = it) }
|
||||
}
|
||||
@@ -249,7 +233,7 @@ class CredentialProviderProcessorImpl(
|
||||
.setAutoSelectAllowed(true)
|
||||
|
||||
if (isVaultUnlocked) {
|
||||
authRepository
|
||||
biometricsEncryptionManager
|
||||
.getOrCreateCipher(userId)
|
||||
?.let { entryBuilder.setBiometricPromptDataIfSupported(cipher = it) }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.credentials.provider.PasswordCredentialEntry
|
||||
import androidx.credentials.provider.PublicKeyCredentialEntry
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.core.util.isBuildVersionAtLeast
|
||||
import com.bitwarden.core.util.isHyperOS
|
||||
import javax.crypto.Cipher
|
||||
|
||||
/**
|
||||
@@ -16,7 +15,7 @@ import javax.crypto.Cipher
|
||||
fun PublicKeyCredentialEntry.Builder.setBiometricPromptDataIfSupported(
|
||||
cipher: Cipher?,
|
||||
): PublicKeyCredentialEntry.Builder =
|
||||
if (isBiometricPromptDataSupported() && cipher != null) {
|
||||
if (isBuildVersionAtLeast(Build.VERSION_CODES.VANILLA_ICE_CREAM) && cipher != null) {
|
||||
setBiometricPromptData(
|
||||
biometricPromptData = buildPromptDataWithCipher(cipher),
|
||||
)
|
||||
@@ -30,19 +29,10 @@ fun PublicKeyCredentialEntry.Builder.setBiometricPromptDataIfSupported(
|
||||
fun PasswordCredentialEntry.Builder.setBiometricPromptDataIfSupported(
|
||||
cipher: Cipher?,
|
||||
): PasswordCredentialEntry.Builder =
|
||||
if (isBiometricPromptDataSupported() && cipher != null) {
|
||||
if (isBuildVersionAtLeast(Build.VERSION_CODES.VANILLA_ICE_CREAM) && cipher != null) {
|
||||
setBiometricPromptData(
|
||||
biometricPromptData = buildPromptDataWithCipher(cipher),
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether biometric prompt data is supported on this device.
|
||||
* Note: Xiaomi HyperOS is known to be incompatible.
|
||||
*/
|
||||
private fun isBiometricPromptDataSupported(): Boolean {
|
||||
return isBuildVersionAtLeast(Build.VERSION_CODES.VANILLA_ICE_CREAM) &&
|
||||
!isHyperOS()
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ class EventDiskSourceImpl(
|
||||
},
|
||||
cipherId = event.cipherId,
|
||||
date = event.date,
|
||||
organizationId = event.organizationId,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -49,7 +48,6 @@ class EventDiskSourceImpl(
|
||||
},
|
||||
cipherId = it.cipherId,
|
||||
date = it.date,
|
||||
organizationId = it.organizationId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.disk
|
||||
|
||||
import com.bitwarden.data.datasource.disk.FlightRecorderDiskSource
|
||||
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
|
||||
@@ -13,7 +13,7 @@ import java.time.Instant
|
||||
* Primary access point for general settings-related disk information.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
interface SettingsDiskSource : FlightRecorderDiskSource {
|
||||
interface SettingsDiskSource {
|
||||
|
||||
/**
|
||||
* The currently persisted app language (or `null` if not set).
|
||||
@@ -95,6 +95,16 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
|
||||
*/
|
||||
val hasUserLoggedInOrCreatedAccountFlow: Flow<Boolean?>
|
||||
|
||||
/**
|
||||
* The current status of whether the flight recorder is enabled.
|
||||
*/
|
||||
var flightRecorderData: FlightRecorderDataSet?
|
||||
|
||||
/**
|
||||
* Emits updates that track [flightRecorderData].
|
||||
*/
|
||||
val flightRecorderDataFlow: Flow<FlightRecorderDataSet?>
|
||||
|
||||
/**
|
||||
* The time at which the browser autofill dialog is allowed to be shown to the user again.
|
||||
*/
|
||||
@@ -105,24 +115,6 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
|
||||
*/
|
||||
fun clearData(userId: String)
|
||||
|
||||
/**
|
||||
* Retrieves the stored value of whether the introducing archive action card has been dismissed.
|
||||
*/
|
||||
fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean?
|
||||
|
||||
/**
|
||||
* Stores whether the introducing archive action card has been dismissed.
|
||||
*/
|
||||
fun storeIntroducingArchiveActionCardDismissed(
|
||||
userId: String,
|
||||
isDismissed: Boolean?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Emits updates that track [getIntroducingArchiveActionCardDismissed] for the given [userId].
|
||||
*/
|
||||
fun getIntroducingArchiveActionCardDismissedFlow(userId: String): Flow<Boolean?>
|
||||
|
||||
/**
|
||||
* Retrieves the biometric integrity validity for the given [userId] and
|
||||
* [systemBioIntegrityState].
|
||||
|
||||
@@ -5,8 +5,8 @@ import androidx.core.content.edit
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.core.data.util.decodeFromStringOrNull
|
||||
import com.bitwarden.data.datasource.disk.BaseDiskSource
|
||||
import com.bitwarden.data.datasource.disk.FlightRecorderDiskSource
|
||||
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
|
||||
@@ -47,10 +47,9 @@ private const val CREATE_ACTION_COUNT = "createActionCount"
|
||||
private const val SHOULD_SHOW_ADD_LOGIN_COACH_MARK = "shouldShowAddLoginCoachMark"
|
||||
private const val SHOULD_SHOW_GENERATOR_COACH_MARK = "shouldShowGeneratorCoachMark"
|
||||
private const val RESUME_SCREEN = "resumeScreen"
|
||||
private const val FLIGHT_RECORDER_KEY = "flightRecorderData"
|
||||
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"
|
||||
|
||||
/**
|
||||
* Primary implementation of [SettingsDiskSource].
|
||||
@@ -59,10 +58,8 @@ private const val INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED =
|
||||
class SettingsDiskSourceImpl(
|
||||
private val sharedPreferences: SharedPreferences,
|
||||
private val json: Json,
|
||||
flightRecorderDiskSource: FlightRecorderDiskSource,
|
||||
) : BaseDiskSource(sharedPreferences = sharedPreferences),
|
||||
SettingsDiskSource,
|
||||
FlightRecorderDiskSource by flightRecorderDiskSource {
|
||||
SettingsDiskSource {
|
||||
private val mutableAppLanguageFlow = bufferedMutableSharedFlow<AppLanguage?>(replay = 1)
|
||||
private val mutableAppThemeFlow = bufferedMutableSharedFlow<AppTheme>(replay = 1)
|
||||
|
||||
@@ -89,15 +86,14 @@ class SettingsDiskSourceImpl(
|
||||
private val mutableShowImportLoginsSettingBadgeFlowMap =
|
||||
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
|
||||
|
||||
private val mutableIntroducingArchiveActionCardDismissedFlowMap =
|
||||
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
|
||||
|
||||
private val mutableIsIconLoadingDisabledFlow = bufferedMutableSharedFlow<Boolean?>()
|
||||
|
||||
private val mutableIsCrashLoggingEnabledFlow = bufferedMutableSharedFlow<Boolean?>()
|
||||
|
||||
private val mutableHasUserLoggedInOrCreatedAccountFlow = bufferedMutableSharedFlow<Boolean?>()
|
||||
|
||||
private val mutableFlightRecorderDataFlow = bufferedMutableSharedFlow<FlightRecorderDataSet?>()
|
||||
|
||||
private val mutableHasSeenAddLoginCoachMarkFlow = bufferedMutableSharedFlow<Boolean?>()
|
||||
|
||||
private val mutableHasSeenGeneratorCoachMarkFlow = bufferedMutableSharedFlow<Boolean?>()
|
||||
@@ -218,6 +214,20 @@ class SettingsDiskSourceImpl(
|
||||
get() = mutableHasUserLoggedInOrCreatedAccountFlow
|
||||
.onSubscription { emit(getBoolean(HAS_USER_LOGGED_IN_OR_CREATED_AN_ACCOUNT_KEY)) }
|
||||
|
||||
override var flightRecorderData: FlightRecorderDataSet?
|
||||
get() = getString(key = FLIGHT_RECORDER_KEY)
|
||||
?.let { json.decodeFromStringOrNull<FlightRecorderDataSet>(it) }
|
||||
set(value) {
|
||||
putString(
|
||||
key = FLIGHT_RECORDER_KEY,
|
||||
value = value?.let { json.encodeToString(it) },
|
||||
)
|
||||
mutableFlightRecorderDataFlow.tryEmit(value)
|
||||
}
|
||||
|
||||
override val flightRecorderDataFlow: Flow<FlightRecorderDataSet?>
|
||||
get() = mutableFlightRecorderDataFlow.onSubscription { emit(flightRecorderData) }
|
||||
|
||||
override var browserAutofillDialogReshowTime: Instant?
|
||||
get() = getLong(key = BROWSER_AUTOFILL_DIALOG_RESHOW_TIME)?.let { Instant.ofEpochMilli(it) }
|
||||
set(value) {
|
||||
@@ -245,29 +255,8 @@ class SettingsDiskSourceImpl(
|
||||
// - show unlock setting badge
|
||||
// - should show add login coach mark
|
||||
// - should show generator coach mark
|
||||
// - should show introducing archive action card dismissed
|
||||
}
|
||||
|
||||
override fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean? =
|
||||
getBoolean(
|
||||
key = INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED.appendIdentifier(identifier = userId),
|
||||
)
|
||||
|
||||
override fun storeIntroducingArchiveActionCardDismissed(
|
||||
userId: String,
|
||||
isDismissed: Boolean?,
|
||||
) {
|
||||
putBoolean(
|
||||
key = INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED.appendIdentifier(identifier = userId),
|
||||
value = isDismissed,
|
||||
)
|
||||
getMutableIntroducingArchiveActionCardDismissedFlow(userId = userId).tryEmit(isDismissed)
|
||||
}
|
||||
|
||||
override fun getIntroducingArchiveActionCardDismissedFlow(userId: String): Flow<Boolean?> =
|
||||
getMutableIntroducingArchiveActionCardDismissedFlow(userId = userId)
|
||||
.onSubscription { emit(getIntroducingArchiveActionCardDismissed(userId = userId)) }
|
||||
|
||||
override fun getAccountBiometricIntegrityValidity(
|
||||
userId: String,
|
||||
systemBioIntegrityState: String,
|
||||
@@ -605,13 +594,6 @@ class SettingsDiskSourceImpl(
|
||||
override fun getAppResumeScreen(userId: String): AppResumeScreenData? =
|
||||
getString(RESUME_SCREEN.appendIdentifier(userId))?.let { json.decodeFromStringOrNull(it) }
|
||||
|
||||
private fun getMutableIntroducingArchiveActionCardDismissedFlow(
|
||||
userId: String,
|
||||
): MutableSharedFlow<Boolean?> =
|
||||
mutableIntroducingArchiveActionCardDismissedFlowMap.getOrPut(userId) {
|
||||
bufferedMutableSharedFlow(replay = 1)
|
||||
}
|
||||
|
||||
private fun getMutableLastSyncFlow(
|
||||
userId: String,
|
||||
): MutableSharedFlow<Instant?> =
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.disk.database
|
||||
|
||||
import androidx.room.AutoMigration
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
@@ -15,11 +14,8 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTyp
|
||||
entities = [
|
||||
OrganizationEventEntity::class,
|
||||
],
|
||||
version = 2,
|
||||
version = 1,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 1, to = 2),
|
||||
],
|
||||
)
|
||||
@TypeConverters(ZonedDateTimeTypeConverter::class)
|
||||
abstract class PlatformDatabase : RoomDatabase() {
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.room.Room
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.data.datasource.disk.FlightRecorderDiskSource
|
||||
import com.bitwarden.data.datasource.disk.di.EncryptedPreferences
|
||||
import com.bitwarden.data.datasource.disk.di.UnencryptedPreferences
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
|
||||
@@ -140,12 +139,10 @@ object PlatformDiskModule {
|
||||
fun provideSettingsDiskSource(
|
||||
@UnencryptedPreferences sharedPreferences: SharedPreferences,
|
||||
json: Json,
|
||||
flightRecorderDiskSource: FlightRecorderDiskSource,
|
||||
): SettingsDiskSource =
|
||||
SettingsDiskSourceImpl(
|
||||
sharedPreferences = sharedPreferences,
|
||||
json = json,
|
||||
flightRecorderDiskSource = flightRecorderDiskSource,
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -25,7 +25,4 @@ data class OrganizationEventEntity(
|
||||
|
||||
@ColumnInfo(name = "date")
|
||||
val date: ZonedDateTime,
|
||||
|
||||
@ColumnInfo(name = "organization_id")
|
||||
val organizationId: String?,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.bitwarden.data.datasource.disk.model
|
||||
package com.x8bit.bitwarden.data.platform.datasource.disk.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.bitwarden.core.data.repository.error
|
||||
package com.x8bit.bitwarden.data.platform.error
|
||||
|
||||
/**
|
||||
* An exception indicating that a required property was missing.
|
||||
@@ -32,6 +32,7 @@ interface BiometricsEncryptionManager {
|
||||
*/
|
||||
fun isBiometricIntegrityValid(
|
||||
userId: String,
|
||||
cipher: Cipher?,
|
||||
): Boolean
|
||||
|
||||
/**
|
||||
|
||||
@@ -90,8 +90,8 @@ class BiometricsEncryptionManagerImpl(
|
||||
return cipher?.takeIf { isCipherInitialized }
|
||||
}
|
||||
|
||||
override fun isBiometricIntegrityValid(userId: String): Boolean =
|
||||
isSystemBiometricIntegrityValid(userId) && isAccountBiometricIntegrityValid(userId)
|
||||
override fun isBiometricIntegrityValid(userId: String, cipher: Cipher?): Boolean =
|
||||
isSystemBiometricIntegrityValid(userId, cipher) && isAccountBiometricIntegrityValid(userId)
|
||||
|
||||
override fun isAccountBiometricIntegrityValid(userId: String): Boolean {
|
||||
val systemBioIntegrityState = settingsDiskSource
|
||||
@@ -203,13 +203,11 @@ class BiometricsEncryptionManagerImpl(
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the keystore key and decrypts it, if decryption is successful `true` is returned,
|
||||
* `false` otherwise.
|
||||
* Validates the keystore key and decrypts it using the user-provided [cipher].
|
||||
*/
|
||||
private fun isSystemBiometricIntegrityValid(userId: String): Boolean {
|
||||
private fun isSystemBiometricIntegrityValid(userId: String, cipher: Cipher?): Boolean {
|
||||
// Attempt to get the user scoped key. If that fails, we check to see if a legacy key
|
||||
// is available.
|
||||
val cipher = getOrCreateCipher(userId = userId)
|
||||
val secretKey = getSecretKeyOrNull(userId = userId) ?: getSecretKeyOrNull(userId = null)
|
||||
return if (cipher != null && secretKey != null) {
|
||||
cipher.initializeCipher(userId = userId, secretKey = secretKey)
|
||||
|
||||
@@ -7,9 +7,9 @@ import android.security.KeyChainException
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.net.toUri
|
||||
import com.bitwarden.core.data.repository.error.MissingPropertyException
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.model.MutualTlsCertificate
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.model.MutualTlsKeyHost
|
||||
import com.x8bit.bitwarden.data.platform.error.MissingPropertyException
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.ImportPrivateKeyResult
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
import timber.log.Timber
|
||||
|
||||
@@ -25,10 +25,4 @@ interface PolicyManager {
|
||||
userId: String,
|
||||
type: PolicyTypeJson,
|
||||
): List<SyncResponseJson.Policy>
|
||||
|
||||
/**
|
||||
* Get the organization id of the personal ownership policy.
|
||||
* If multiple organizations enforce the policy, return the first to set it.
|
||||
*/
|
||||
fun getPersonalOwnershipPolicyOrganizationId(): String?
|
||||
}
|
||||
|
||||
@@ -66,13 +66,6 @@ class PolicyManagerImpl(
|
||||
)
|
||||
.orEmpty()
|
||||
|
||||
override fun getPersonalOwnershipPolicyOrganizationId(): String? =
|
||||
this
|
||||
.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
.sortedBy { it.revisionDate }
|
||||
.firstOrNull()
|
||||
?.organizationId
|
||||
|
||||
/**
|
||||
* A helper method to filter policies.
|
||||
*/
|
||||
|
||||
@@ -34,8 +34,6 @@ import java.time.Clock
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
import javax.inject.Inject
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.contract
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.toJavaDuration
|
||||
@@ -136,6 +134,7 @@ class PushManagerImpl @Inject constructor(
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
private fun onMessageReceived(notification: BitwardenNotification) {
|
||||
if (authDiskSource.uniqueAppId == notification.contextId) return
|
||||
val userId = activeUserId ?: return
|
||||
Timber.d("Push Notification Received: ${notification.notificationType}")
|
||||
|
||||
when (val type = notification.notificationType) {
|
||||
@@ -180,13 +179,11 @@ class PushManagerImpl @Inject constructor(
|
||||
.decodeFromString<NotificationPayload.SyncCipherNotification>(
|
||||
string = notification.payload,
|
||||
)
|
||||
.takeIf {
|
||||
it.cipherId != null && it.revisionDate != null && isLoggedIn(it.userId)
|
||||
}
|
||||
.takeIf { isLoggedIn(userId) && it.userMatchesNotification(userId) }
|
||||
?.takeIf { it.cipherId != null && it.revisionDate != null }
|
||||
?.let {
|
||||
mutableSyncCipherUpsertSharedFlow.tryEmit(
|
||||
SyncCipherUpsertData(
|
||||
userId = requireNotNull(it.userId),
|
||||
cipherId = requireNotNull(it.cipherId),
|
||||
revisionDate = requireNotNull(it.revisionDate),
|
||||
organizationId = it.organizationId,
|
||||
@@ -231,13 +228,11 @@ class PushManagerImpl @Inject constructor(
|
||||
.decodeFromString<NotificationPayload.SyncFolderNotification>(
|
||||
string = notification.payload,
|
||||
)
|
||||
.takeIf {
|
||||
it.folderId != null && it.revisionDate != null && isLoggedIn(it.userId)
|
||||
}
|
||||
.takeIf { isLoggedIn(userId) && it.userMatchesNotification(userId) }
|
||||
?.takeIf { it.folderId != null && it.revisionDate != null }
|
||||
?.let {
|
||||
mutableSyncFolderUpsertSharedFlow.tryEmit(
|
||||
SyncFolderUpsertData(
|
||||
userId = requireNotNull(it.userId),
|
||||
folderId = requireNotNull(it.folderId),
|
||||
revisionDate = requireNotNull(it.revisionDate),
|
||||
isUpdate = type == NotificationType.SYNC_FOLDER_UPDATE,
|
||||
@@ -278,13 +273,11 @@ class PushManagerImpl @Inject constructor(
|
||||
.decodeFromString<NotificationPayload.SyncSendNotification>(
|
||||
string = notification.payload,
|
||||
)
|
||||
.takeIf {
|
||||
it.sendId != null && it.revisionDate != null && isLoggedIn(it.userId)
|
||||
}
|
||||
.takeIf { isLoggedIn(userId) && it.userMatchesNotification(userId) }
|
||||
?.takeIf { it.sendId != null && it.revisionDate != null }
|
||||
?.let {
|
||||
mutableSyncSendUpsertSharedFlow.tryEmit(
|
||||
SyncSendUpsertData(
|
||||
userId = requireNotNull(it.userId),
|
||||
sendId = requireNotNull(it.sendId),
|
||||
revisionDate = requireNotNull(it.revisionDate),
|
||||
isUpdate = type == NotificationType.SYNC_SEND_UPDATE,
|
||||
@@ -368,11 +361,11 @@ class PushManagerImpl @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
private fun isLoggedIn(
|
||||
userId: String?,
|
||||
): Boolean {
|
||||
contract { returns(true) implies (userId != null) }
|
||||
return userId?.let { authDiskSource.getAccountTokens(it) }?.isLoggedIn == true
|
||||
}
|
||||
userId: String,
|
||||
): Boolean = authDiskSource.getAccountTokens(userId)?.isLoggedIn == true
|
||||
}
|
||||
|
||||
private fun NotificationPayload.userMatchesNotification(userId: String): Boolean {
|
||||
return this.userId != null && this.userId == userId
|
||||
}
|
||||
|
||||
@@ -63,6 +63,10 @@ import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardMan
|
||||
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.flightrecorder.FlightRecorderManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.flightrecorder.FlightRecorderManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.flightrecorder.FlightRecorderWriter
|
||||
import com.x8bit.bitwarden.data.platform.manager.flightrecorder.FlightRecorderWriterImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConfigManager
|
||||
@@ -80,6 +84,7 @@ import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
|
||||
import com.x8bit.bitwarden.data.vault.manager.FileManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import dagger.Module
|
||||
@@ -104,6 +109,34 @@ object PlatformManagerModule {
|
||||
application: Application,
|
||||
): AppStateManager = AppStateManagerImpl(application = application)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideFlightRecorderWriter(
|
||||
clock: Clock,
|
||||
fileManager: FileManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
): FlightRecorderWriter = FlightRecorderWriterImpl(
|
||||
clock = clock,
|
||||
fileManager = fileManager,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideFlightRecorderManager(
|
||||
@ApplicationContext context: Context,
|
||||
clock: Clock,
|
||||
dispatcherManager: DispatcherManager,
|
||||
settingsDiskSource: SettingsDiskSource,
|
||||
flightRecorderWriter: FlightRecorderWriter,
|
||||
): FlightRecorderManager = FlightRecorderManagerImpl(
|
||||
context = context,
|
||||
clock = clock,
|
||||
dispatcherManager = dispatcherManager,
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
flightRecorderWriter = flightRecorderWriter,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthenticatorBridgeProcessor(
|
||||
|
||||
@@ -79,7 +79,6 @@ class OrganizationEventManagerImpl(
|
||||
type = event.type,
|
||||
cipherId = event.cipherId,
|
||||
date = ZonedDateTime.now(clock),
|
||||
organizationId = event.organizationId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.bitwarden.data.manager.flightrecorder
|
||||
package com.x8bit.bitwarden.data.platform.manager.flightrecorder
|
||||
|
||||
import com.bitwarden.data.datasource.disk.model.FlightRecorderDataSet
|
||||
import com.bitwarden.data.manager.model.FlightRecorderDuration
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.FlightRecorderDuration
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.bitwarden.data.manager.flightrecorder
|
||||
package com.x8bit.bitwarden.data.platform.manager.flightrecorder
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
@@ -7,9 +7,9 @@ import android.content.IntentFilter
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.core.data.util.concurrentMapOf
|
||||
import com.bitwarden.core.data.util.toFormattedPattern
|
||||
import com.bitwarden.data.datasource.disk.FlightRecorderDiskSource
|
||||
import com.bitwarden.data.datasource.disk.model.FlightRecorderDataSet
|
||||
import com.bitwarden.data.manager.model.FlightRecorderDuration
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.FlightRecorderDuration
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -33,7 +33,7 @@ private const val EXPIRATION_DURATION_DAYS: Long = 30
|
||||
internal class FlightRecorderManagerImpl(
|
||||
private val context: Context,
|
||||
private val clock: Clock,
|
||||
private val flightRecorderDiskSource: FlightRecorderDiskSource,
|
||||
private val settingsDiskSource: SettingsDiskSource,
|
||||
private val flightRecorderWriter: FlightRecorderWriter,
|
||||
dispatcherManager: DispatcherManager,
|
||||
) : FlightRecorderManager {
|
||||
@@ -44,12 +44,10 @@ internal class FlightRecorderManagerImpl(
|
||||
private val flightRecorderTree = FlightRecorderTree()
|
||||
|
||||
override val flightRecorderData: FlightRecorderDataSet
|
||||
get() = flightRecorderDiskSource
|
||||
.flightRecorderData
|
||||
?: FlightRecorderDataSet(data = emptySet())
|
||||
get() = settingsDiskSource.flightRecorderData ?: FlightRecorderDataSet(data = emptySet())
|
||||
|
||||
override val flightRecorderDataFlow: StateFlow<FlightRecorderDataSet>
|
||||
get() = flightRecorderDiskSource
|
||||
get() = settingsDiskSource
|
||||
.flightRecorderDataFlow
|
||||
.map { it ?: FlightRecorderDataSet(data = emptySet()) }
|
||||
.stateIn(
|
||||
@@ -75,7 +73,7 @@ internal class FlightRecorderManagerImpl(
|
||||
|
||||
override fun dismissFlightRecorderBanner() {
|
||||
val originalData = flightRecorderData
|
||||
flightRecorderDiskSource.flightRecorderData = originalData.copy(
|
||||
settingsDiskSource.flightRecorderData = originalData.copy(
|
||||
data = originalData.data.map { it.copy(isBannerDismissed = true) }.toSet(),
|
||||
)
|
||||
}
|
||||
@@ -83,7 +81,7 @@ internal class FlightRecorderManagerImpl(
|
||||
override fun startFlightRecorder(duration: FlightRecorderDuration) {
|
||||
val startTime = clock.instant()
|
||||
val originalData = flightRecorderData
|
||||
flightRecorderDiskSource.flightRecorderData = originalData.copy(
|
||||
settingsDiskSource.flightRecorderData = originalData.copy(
|
||||
data = originalData
|
||||
.data
|
||||
.mapToInactive(clock = clock)
|
||||
@@ -108,7 +106,7 @@ internal class FlightRecorderManagerImpl(
|
||||
|
||||
override fun endFlightRecorder() {
|
||||
val originalData = flightRecorderData
|
||||
flightRecorderDiskSource.flightRecorderData = originalData.copy(
|
||||
settingsDiskSource.flightRecorderData = originalData.copy(
|
||||
data = originalData.data.mapToInactive(clock = clock),
|
||||
)
|
||||
}
|
||||
@@ -119,7 +117,7 @@ internal class FlightRecorderManagerImpl(
|
||||
data = flightRecorderData.data.filterNot { it.isActive }.toSet(),
|
||||
)
|
||||
// Clear everything but the active log.
|
||||
flightRecorderDiskSource.flightRecorderData = activeLog?.let {
|
||||
settingsDiskSource.flightRecorderData = activeLog?.let {
|
||||
FlightRecorderDataSet(data = setOf(it))
|
||||
}
|
||||
// Clear all logs but the active one.
|
||||
@@ -129,7 +127,7 @@ internal class FlightRecorderManagerImpl(
|
||||
override fun deleteLog(data: FlightRecorderDataSet.FlightRecorderData) {
|
||||
if (data.isActive) return
|
||||
val originalData = flightRecorderData
|
||||
flightRecorderDiskSource.flightRecorderData = originalData.copy(
|
||||
settingsDiskSource.flightRecorderData = originalData.copy(
|
||||
data = originalData.data.filterNot { it == data }.toSet(),
|
||||
)
|
||||
ioScope.launch { flightRecorderWriter.deleteLog(data = data) }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user