mirror of
https://github.com/bitwarden/android.git
synced 2026-05-11 10:54:26 -05:00
Compare commits
56 Commits
autofill-p
...
v2025.12.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34888f8bc3 | ||
|
|
0975144342 | ||
|
|
07415844ee | ||
|
|
913d877737 | ||
|
|
c16da5090e | ||
|
|
b79aca7338 | ||
|
|
7834d5bf27 | ||
|
|
7c929c3713 | ||
|
|
7f032a8732 | ||
|
|
ef6714fa17 | ||
|
|
d09945d80b | ||
|
|
30ce512091 | ||
|
|
bdbcd5bdc2 | ||
|
|
b4414073c7 | ||
|
|
1594de39c1 | ||
|
|
f0c5c8f421 | ||
|
|
2a343555bf | ||
|
|
dff6a13cd7 | ||
|
|
e415145c53 | ||
|
|
54ea921b25 | ||
|
|
e87ffa3902 | ||
|
|
00cded3a02 | ||
|
|
1503e3f769 | ||
|
|
6840a6c207 | ||
|
|
d32e767c62 | ||
|
|
4a874668f2 | ||
|
|
cd27fe339d | ||
|
|
2eb8ad4221 | ||
|
|
28db795790 | ||
|
|
8c6782dcb1 | ||
|
|
127809b8df | ||
|
|
ca13e615ec | ||
|
|
5e3e8a04aa | ||
|
|
8077895eb8 | ||
|
|
33e9313c6c | ||
|
|
593bfbf8cf | ||
|
|
4905358adb | ||
|
|
02733f785b | ||
|
|
8baa4bf041 | ||
|
|
4d20453d0f | ||
|
|
4b951a1df2 | ||
|
|
9349b235bc | ||
|
|
e9ab5f2def | ||
|
|
3bef282426 | ||
|
|
e1bb3a4b5d | ||
|
|
1904c4ffb9 | ||
|
|
26e7178300 | ||
|
|
2c01abda46 | ||
|
|
b86cbfcd87 | ||
|
|
3f303d3f39 | ||
|
|
ca7a65fc95 | ||
|
|
f02b374e98 | ||
|
|
1a90860080 | ||
|
|
adf83cd315 | ||
|
|
489c0ea8d6 | ||
|
|
9831358a8b |
@@ -1,27 +1,3 @@
|
||||
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,56 +1,34 @@
|
||||
---
|
||||
name: reviewing-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.
|
||||
version: 3.0.0
|
||||
description: Android-specific code review workflow additions for Bitwarden Android. Provides change type refinements, checklist loading, and reference material organization. Complements bitwarden-code-reviewer agent's base review standards.
|
||||
---
|
||||
|
||||
# Reviewing Changes
|
||||
# Reviewing Changes - Android Additions
|
||||
|
||||
This skill provides Android-specific workflow additions that complement the base `bitwarden-code-reviewer` agent standards.
|
||||
|
||||
## Instructions
|
||||
|
||||
**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.
|
||||
**IMPORTANT**: Use structured thinking throughout your review process. Plan your analysis in `<thinking>` tags before providing final feedback.
|
||||
|
||||
### Step 1: Check for Existing Review Threads
|
||||
|
||||
Always check for existing comment threads to avoid duplicate comments:
|
||||
### Step 1: Retrieve Additional Details
|
||||
|
||||
<thinking>
|
||||
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?
|
||||
Determine if more context is available for the changes:
|
||||
1. Are there JIRA tickets or GitHub Issues mentioned in the PR title or body?
|
||||
2. Are there other GitHub pull requests mentioned in the PR title or body?
|
||||
</thinking>
|
||||
|
||||
**Thread Detection Procedure:**
|
||||
Retrieve any additional information linked to the pull request using available tools (JIRA MCP, GitHub API).
|
||||
|
||||
1. **Fetch existing comment count:**
|
||||
```bash
|
||||
gh pr view <pr-number> --json comments --jq '.comments | length'
|
||||
```
|
||||
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
|
||||
|
||||
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
|
||||
### Step 2: Detect Change Type with Android Refinements
|
||||
|
||||
<thinking>
|
||||
Analyze the changeset systematically:
|
||||
@@ -60,17 +38,13 @@ Analyze the changeset systematically:
|
||||
4. What's the risk level of these changes?
|
||||
</thinking>
|
||||
|
||||
Analyze the changeset to determine the primary change type:
|
||||
Use the base change type detection from the agent, with Android-specific refinements:
|
||||
|
||||
**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.
|
||||
**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
|
||||
|
||||
### Step 3: Load Appropriate Checklist
|
||||
|
||||
@@ -89,7 +63,7 @@ The checklist provides:
|
||||
- What to check and what to skip
|
||||
- Structured thinking guidance
|
||||
|
||||
### Step 4: Execute Review with Structured Thinking
|
||||
### Step 4: Execute Review Following Checklist
|
||||
|
||||
<thinking>
|
||||
Before diving into details:
|
||||
@@ -102,7 +76,7 @@ Before diving into details:
|
||||
|
||||
Follow the checklist's multi-pass strategy, thinking through each pass systematically.
|
||||
|
||||
### Step 5: Consult Reference Materials As Needed
|
||||
### Step 5: Consult Android Reference Materials As Needed
|
||||
|
||||
Load reference files only when needed for specific questions:
|
||||
|
||||
@@ -115,206 +89,10 @@ 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
|
||||
- **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
|
||||
- **Efficient reviews**: Use multi-pass strategy, skip what's not relevant
|
||||
- **Android patterns**: Validate MVVM, Hilt DI, Compose conventions, Kotlin idioms
|
||||
|
||||
@@ -2,6 +2,27 @@
|
||||
|
||||
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
|
||||
@@ -25,10 +46,11 @@ Reference: [docs link if applicable]
|
||||
```
|
||||
|
||||
**Severity Levels:**
|
||||
- ⚠️ **CRITICAL** - Blocking, must fix
|
||||
- 📋 **IMPORTANT** - Should fix
|
||||
- 💡 **SUGGESTED** - Nice to have
|
||||
- ❓ **QUESTION** - Seeking clarification
|
||||
- ❌ **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)
|
||||
|
||||
### Summary Comment Format
|
||||
|
||||
@@ -81,7 +103,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>
|
||||
@@ -136,7 +158,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>
|
||||
@@ -160,7 +182,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>
|
||||
@@ -188,7 +210,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>
|
||||
@@ -214,7 +236,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>
|
||||
@@ -246,7 +268,7 @@ Would add security layer against brute force. Consider discussing threat model w
|
||||
|
||||
**Inline Comment 5** (on `app/vault/unlock/UnlockScreen.kt:134`):
|
||||
```markdown
|
||||
❓ **QUESTION**: Can we use BitwardenTextField?
|
||||
💭 **QUESTION**: Can we use BitwardenTextField?
|
||||
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
@@ -356,7 +378,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.
|
||||
|
||||
@@ -382,7 +404,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>
|
||||
@@ -422,5 +444,3 @@ 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,6 +2,22 @@
|
||||
|
||||
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)
|
||||
- [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
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
# Issue Priority Framework
|
||||
# Finding Priority Framework
|
||||
|
||||
Use this framework to classify findings during code review. Clear prioritization helps authors triage and address issues effectively.
|
||||
|
||||
## Critical (Blocker - Must Fix Before Merge)
|
||||
## 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)
|
||||
|
||||
These issues **must** be addressed before the PR can be merged. They pose immediate risks to security, stability, or architecture integrity.
|
||||
|
||||
@@ -49,7 +69,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.
|
||||
|
||||
@@ -102,7 +122,53 @@ Fetching items one-by-one in loop. Consider batch fetch to reduce database queri
|
||||
|
||||
---
|
||||
|
||||
## Suggested (Nice to Have)
|
||||
## ♻️ **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)
|
||||
|
||||
These are improvement opportunities but not required. Consider the effort vs. benefit before requesting changes.
|
||||
|
||||
@@ -142,6 +208,80 @@ 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.
|
||||
@@ -175,11 +315,26 @@ 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
|
||||
@@ -270,5 +425,7 @@ 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,6 +2,20 @@
|
||||
|
||||
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.
|
||||
|
||||
@@ -44,6 +44,5 @@ runs:
|
||||
- name: Install Fastlane
|
||||
shell: bash
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
3
.github/label-pr.json
vendored
3
.github/label-pr.json
vendored
@@ -23,7 +23,8 @@
|
||||
],
|
||||
"app:password-manager": [
|
||||
"app/",
|
||||
"cxf/"
|
||||
"cxf/",
|
||||
"testharness/"
|
||||
],
|
||||
"app:authenticator": [
|
||||
"authenticator/"
|
||||
|
||||
@@ -40,7 +40,7 @@ Single line of release notes text
|
||||
|
||||
```json
|
||||
...
|
||||
"customfield_10335": {
|
||||
"customfield_9999": {
|
||||
"type": "doc",
|
||||
"version": 1,
|
||||
"content": [
|
||||
@@ -62,7 +62,7 @@ Single line of release notes text
|
||||
|
||||
```json
|
||||
...
|
||||
"customfield_10335": {
|
||||
"customfield_9999": {
|
||||
"type": "doc",
|
||||
"version": 1,
|
||||
"content": [
|
||||
|
||||
@@ -5,6 +5,8 @@ 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]
|
||||
@@ -23,19 +25,42 @@ def extract_text_from_content(content):
|
||||
|
||||
return ''
|
||||
|
||||
def parse_release_notes(response_json):
|
||||
try:
|
||||
fields = response_json.get('fields', {})
|
||||
release_notes_field = fields.get('customfield_10335', {})
|
||||
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)
|
||||
|
||||
if not release_notes_field or not release_notes_field.get('content'):
|
||||
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)
|
||||
return ''
|
||||
|
||||
release_notes = extract_text_from_content(release_notes_field.get('content', []))
|
||||
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)
|
||||
return release_notes
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error parsing release notes: {str(e)}", file=sys.stderr)
|
||||
print(f"[{SCRIPT_NAME}] Error parsing release notes: {str(e)}", file=sys.stderr)
|
||||
return ''
|
||||
|
||||
def main():
|
||||
@@ -60,7 +85,7 @@ def main():
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"Error fetching Jira issue: {response.status_code}", file=sys.stderr)
|
||||
print(f"[{SCRIPT_NAME}] Error fetching Jira issue ({jira_issue_id}). Status code: {response.status_code}. Msg: {response.text}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
release_notes = parse_release_notes(response.json())
|
||||
|
||||
2
.github/workflows/_version.yml
vendored
2
.github/workflows/_version.yml
vendored
@@ -79,7 +79,7 @@ jobs:
|
||||
|
||||
- name: Check out repository
|
||||
if: ${{ !inputs.skip_checkout || false }}
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
6
.github/workflows/build-authenticator.yml
vendored
6
.github/workflows/build-authenticator.yml
vendored
@@ -59,7 +59,7 @@ jobs:
|
||||
inputs: "${{ toJson(inputs) }}"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -98,7 +98,6 @@ jobs:
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
@@ -123,7 +122,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -134,7 +133,6 @@ jobs:
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
|
||||
134
.github/workflows/build-testharness.yml
vendored
Normal file
134
.github/workflows/build-testharness.yml
vendored
Normal file
@@ -0,0 +1,134 @@
|
||||
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
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@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: com.bitwarden.testharness.dev.apk-sha256.txt
|
||||
path: ./com.bitwarden.testharness.dev.apk-sha256.txt
|
||||
if-no-files-found: error
|
||||
9
.github/workflows/build.yml
vendored
9
.github/workflows/build.yml
vendored
@@ -61,7 +61,7 @@ jobs:
|
||||
inputs: "${{ toJson(inputs) }}"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -100,7 +100,6 @@ jobs:
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
@@ -132,7 +131,7 @@ jobs:
|
||||
artifact: ["apk", "aab"]
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -143,7 +142,6 @@ jobs:
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
@@ -451,7 +449,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -462,7 +460,6 @@ jobs:
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ name: Cron / Sync Google Privileged Browsers List
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run weekly on Monday at 00:00 UTC
|
||||
- cron: "0 0 * * 1"
|
||||
# Run weekly on Sunday at 00:00 UTC
|
||||
- cron: '0 0 * * 0'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: true
|
||||
|
||||
|
||||
12
.github/workflows/crowdin-pull.yml
vendored
12
.github/workflows/crowdin-pull.yml
vendored
@@ -4,19 +4,21 @@ run-name: Crowdin Pull - ${{ github.event_name == 'workflow_dispatch' && 'Manual
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 0 * * 5"
|
||||
# Run weekly on Sunday at 00:00 UTC
|
||||
- cron: '0 0 * * 0'
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
crowdin-sync:
|
||||
name: Crowdin Pull - ${{ github.event_name }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -50,6 +52,8 @@ 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
|
||||
|
||||
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
@@ -183,11 +183,15 @@ 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"
|
||||
product_release_notes=$(python3 .github/scripts/jira-get-release-notes/jira_release_notes.py "$_RELEASE_TICKET_ID" "$_JIRA_API_EMAIL" "$_JIRA_API_TOKEN")
|
||||
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 "--------------------------------"
|
||||
|
||||
if [[ -z "$product_release_notes" || $product_release_notes == "Error checking"* ]]; then
|
||||
echo "::warning::Failed to fetch release notes from Jira. Output: $product_release_notes"
|
||||
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."
|
||||
product_release_notes="<insert product release notes here>"
|
||||
else
|
||||
echo "✅ Product release notes:"
|
||||
@@ -285,5 +289,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"
|
||||
|
||||
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Configure Ruby
|
||||
@@ -83,7 +83,6 @@ 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
|
||||
2
.github/workflows/sdlc-label-pr.yml
vendored
2
.github/workflows/sdlc-label-pr.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
4
.github/workflows/sdlc-sdk-update.yml
vendored
4
.github/workflows/sdlc-sdk-update.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
permission-contents: write
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
fetch-depth: 0
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -65,7 +65,6 @@ jobs:
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
|
||||
3
Gemfile
3
Gemfile
@@ -14,5 +14,8 @@ 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'
|
||||
|
||||
37
Gemfile.lock
37
Gemfile.lock
@@ -1,18 +1,15 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.7)
|
||||
base64
|
||||
nkf
|
||||
rexml
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
addressable (2.8.8)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1181.0)
|
||||
aws-sdk-core (3.236.0)
|
||||
aws-partitions (1.1190.0)
|
||||
aws-sdk-core (3.239.2)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@@ -20,10 +17,10 @@ GEM
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.117.0)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
aws-sdk-kms (1.118.0)
|
||||
aws-sdk-core (~> 3, >= 3.239.1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.203.0)
|
||||
aws-sdk-s3 (1.206.0)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
@@ -58,9 +55,9 @@ GEM
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-cookie_jar (0.0.7)
|
||||
faraday-cookie_jar (0.0.8)
|
||||
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)
|
||||
@@ -75,8 +72,9 @@ GEM
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.0)
|
||||
fastlane (2.228.0)
|
||||
fastlane (2.229.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
@@ -84,6 +82,7 @@ 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)
|
||||
@@ -103,6 +102,7 @@ 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,13 +169,13 @@ GEM
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.16.0)
|
||||
json (2.17.1)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.17.0)
|
||||
multi_json (1.18.0)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
@@ -185,7 +185,7 @@ GEM
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
public_suffix (6.0.2)
|
||||
public_suffix (7.0.0)
|
||||
rake (13.3.1)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
@@ -241,6 +241,7 @@ DEPENDENCIES
|
||||
fastlane-plugin-firebase_app_distribution
|
||||
logger
|
||||
mutex_m
|
||||
nkf
|
||||
ostruct
|
||||
time
|
||||
|
||||
@@ -248,4 +249,4 @@ RUBY VERSION
|
||||
ruby 3.4.2p28
|
||||
|
||||
BUNDLED WITH
|
||||
2.6.9
|
||||
2.6.2
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<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" />
|
||||
@@ -30,10 +31,6 @@
|
||||
android:protectionLevel="signature|knownSigner"
|
||||
tools:targetApi="s" />
|
||||
|
||||
<permission
|
||||
android:name="${applicationId}.permission.AUTOFILL_CALLBACK"
|
||||
android:protectionLevel="signature" />
|
||||
|
||||
<application
|
||||
android:name=".BitwardenApplication"
|
||||
android:allowBackup="false"
|
||||
@@ -135,8 +132,7 @@
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:noHistory="true"
|
||||
android:theme="@style/AutofillCallbackTheme"
|
||||
android:permission="${applicationId}.permission.AUTOFILL_CALLBACK" />
|
||||
android:theme="@style/AutofillCallbackTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".AuthCallbackActivity"
|
||||
@@ -266,7 +262,7 @@
|
||||
android:name="com.x8bit.bitwarden.AutofillTileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_notification"
|
||||
android:label="@string/autofill_title"
|
||||
android:label="@string/autofill_verb"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
tools:ignore="MissingClass">
|
||||
<intent-filter>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,7 +30,7 @@ class AuthSdkSourceImpl(
|
||||
getClient()
|
||||
.auth()
|
||||
.newAuthRequest(
|
||||
email = email,
|
||||
email = email.lowercase(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ class AuthSdkSourceImpl(
|
||||
.platform()
|
||||
.fingerprint(
|
||||
req = FingerprintRequest(
|
||||
fingerprintMaterial = email,
|
||||
fingerprintMaterial = email.lowercase(),
|
||||
publicKey = publicKey,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -28,7 +28,6 @@ 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
|
||||
@@ -163,7 +162,7 @@ class AuthRequestManagerImpl(
|
||||
emit(result)
|
||||
if (result is AuthRequestUpdatesResult.Error) return@flow
|
||||
var isComplete = false
|
||||
while (coroutineContext.isActive && !isComplete) {
|
||||
while (currentCoroutineContext().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 isExpired = reason == LogoutReason.SecurityStamp
|
||||
if (isExpired) {
|
||||
val isSecurityStamp = reason == LogoutReason.SecurityStamp
|
||||
if (isSecurityStamp) {
|
||||
showToast(message = BitwardenString.login_expired)
|
||||
}
|
||||
|
||||
val ableToSwitchToNewAccount = switchUserIfAvailable(
|
||||
currentUserId = userId,
|
||||
isExpired = isExpired,
|
||||
isSecurityStamp = isSecurityStamp,
|
||||
removeCurrentUserFromAccounts = true,
|
||||
)
|
||||
|
||||
@@ -73,19 +73,24 @@ class UserLogoutManagerImpl(
|
||||
|
||||
override fun softLogout(userId: String, reason: LogoutReason) {
|
||||
Timber.d("softLogout reason=$reason")
|
||||
val isExpired = reason == LogoutReason.SecurityStamp
|
||||
if (isExpired) {
|
||||
val isSecurityStamp = reason == LogoutReason.SecurityStamp
|
||||
if (isSecurityStamp) {
|
||||
showToast(message = BitwardenString.login_expired)
|
||||
}
|
||||
|
||||
// Save any data that will still need to be retained after otherwise clearing all dat
|
||||
// Save any data that will still need to be retained after otherwise clearing all data
|
||||
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,
|
||||
isExpired = isExpired,
|
||||
isSecurityStamp = isSecurityStamp,
|
||||
)
|
||||
|
||||
clearData(userId = userId)
|
||||
@@ -102,6 +107,14 @@ 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) {
|
||||
@@ -123,7 +136,7 @@ class UserLogoutManagerImpl(
|
||||
private fun switchUserIfAvailable(
|
||||
currentUserId: String,
|
||||
removeCurrentUserFromAccounts: Boolean,
|
||||
isExpired: Boolean = false,
|
||||
isSecurityStamp: Boolean,
|
||||
): Boolean {
|
||||
val currentUserState = authDiskSource.userState ?: return false
|
||||
|
||||
@@ -135,7 +148,7 @@ class UserLogoutManagerImpl(
|
||||
|
||||
// Check if there is a new active user
|
||||
return if (updatedAccounts.isNotEmpty()) {
|
||||
if (currentUserId == currentUserState.activeUserId && !isExpired) {
|
||||
if (currentUserId == currentUserState.activeUserId && !isSecurityStamp) {
|
||||
showToast(message = BitwardenString.account_switched_automatically)
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ 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
|
||||
|
||||
@@ -48,6 +49,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
interface AuthRepository :
|
||||
AuthenticatorProvider,
|
||||
AuthRequestManager,
|
||||
BiometricsEncryptionManager,
|
||||
KdfManager,
|
||||
UserStateManager {
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.auth.repository
|
||||
import com.bitwarden.core.AuthRequestMethod
|
||||
import com.bitwarden.core.InitUserCryptoMethod
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
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
|
||||
@@ -100,8 +101,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
|
||||
@@ -157,6 +158,7 @@ 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,
|
||||
@@ -168,6 +170,7 @@ class AuthRepositoryImpl(
|
||||
dispatcherManager: DispatcherManager,
|
||||
) : AuthRepository,
|
||||
AuthRequestManager by authRequestManager,
|
||||
BiometricsEncryptionManager by biometricsEncryptionManager,
|
||||
KdfManager by kdfManager,
|
||||
UserStateManager by userStateManager {
|
||||
/**
|
||||
@@ -1356,10 +1359,10 @@ class AuthRepositoryImpl(
|
||||
)
|
||||
.fold(
|
||||
onSuccess = {
|
||||
when (val json = it) {
|
||||
when (it) {
|
||||
VerifyEmailTokenResponseJson.Valid -> EmailTokenResult.Success
|
||||
is VerifyEmailTokenResponseJson.Invalid -> {
|
||||
EmailTokenResult.Error(message = json.message, error = null)
|
||||
EmailTokenResult.Error(message = it.message, error = null)
|
||||
}
|
||||
|
||||
VerifyEmailTokenResponseJson.TokenExpired -> EmailTokenResult.Expired
|
||||
@@ -1883,21 +1886,15 @@ 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 initUserCryptoMethod = loginResponse
|
||||
val masterPasswordUnlock = loginResponse
|
||||
.userDecryptionOptions
|
||||
?.masterPasswordUnlock
|
||||
?.let { masterPasswordUnlock ->
|
||||
InitUserCryptoMethod.MasterPasswordUnlock(
|
||||
password = masterPassword,
|
||||
masterPasswordUnlock = masterPasswordUnlock.toSdkMasterPasswordUnlock(),
|
||||
)
|
||||
}
|
||||
?: InitUserCryptoMethod.Password(
|
||||
password = masterPassword,
|
||||
userKey = key,
|
||||
)
|
||||
?: return null
|
||||
val initUserCryptoMethod = InitUserCryptoMethod.MasterPasswordUnlock(
|
||||
password = masterPassword,
|
||||
masterPasswordUnlock = masterPasswordUnlock.toSdkMasterPasswordUnlock(),
|
||||
)
|
||||
|
||||
return unlockVault(
|
||||
accountProfile = profile,
|
||||
|
||||
@@ -19,6 +19,7 @@ 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
|
||||
@@ -60,6 +61,7 @@ object AuthRepositoryModule {
|
||||
environmentRepository: EnvironmentRepository,
|
||||
settingsRepository: SettingsRepository,
|
||||
vaultRepository: VaultRepository,
|
||||
biometricsEncryptionManager: BiometricsEncryptionManager,
|
||||
keyConnectorManager: KeyConnectorManager,
|
||||
authRequestManager: AuthRequestManager,
|
||||
trustedDeviceManager: TrustedDeviceManager,
|
||||
@@ -85,6 +87,7 @@ object AuthRepositoryModule {
|
||||
environmentRepository = environmentRepository,
|
||||
settingsRepository = settingsRepository,
|
||||
vaultRepository = vaultRepository,
|
||||
biometricsEncryptionManager = biometricsEncryptionManager,
|
||||
keyConnectorManager = keyConnectorManager,
|
||||
authRequestManager = authRequestManager,
|
||||
trustedDeviceManager = trustedDeviceManager,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.model
|
||||
|
||||
/**
|
||||
* Models result of deleting an account.
|
||||
* Models result of leaving an organization.
|
||||
*/
|
||||
sealed class LeaveOrganizationResult {
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.auth.util
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
|
||||
|
||||
/**
|
||||
@@ -14,7 +14,7 @@ fun Intent.getCompleteRegistrationDataIntentOrNull(): CompleteRegistrationData?
|
||||
newValue = "/",
|
||||
ignoreCase = true,
|
||||
)
|
||||
val uri = runCatching { Uri.parse(sanitizedUriString) }.getOrNull() ?: return null
|
||||
val uri = runCatching { sanitizedUriString.toUri() }.getOrNull() ?: return null
|
||||
uri.host ?: return null
|
||||
if (uri.path != "/finish-signup") return null
|
||||
val email = uri.getQueryParameter("email") ?: return null
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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
|
||||
|
||||
@@ -10,7 +11,7 @@ import java.net.URISyntaxException
|
||||
@OmitFromCoverage
|
||||
fun String.toUriOrNull(): Uri? =
|
||||
try {
|
||||
Uri.parse(this)
|
||||
} catch (e: URISyntaxException) {
|
||||
this.toUri()
|
||||
} catch (_: URISyntaxException) {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -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.platform.manager.BiometricsEncryptionManager
|
||||
class CredentialEntryBuilderImpl(
|
||||
private val context: Context,
|
||||
private val pendingIntentManager: CredentialManagerPendingIntentManager,
|
||||
private val biometricsEncryptionManager: BiometricsEncryptionManager,
|
||||
private val authRepository: AuthRepository,
|
||||
) : CredentialEntryBuilder {
|
||||
|
||||
override fun buildPublicKeyCredentialEntries(
|
||||
@@ -82,7 +82,7 @@ class CredentialEntryBuilderImpl(
|
||||
.also { builder ->
|
||||
if (!isUserVerified) {
|
||||
builder.setBiometricPromptDataIfSupported(
|
||||
cipher = biometricsEncryptionManager.getOrCreateCipher(userId),
|
||||
cipher = authRepository.getOrCreateCipher(userId),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -113,8 +113,7 @@ class CredentialEntryBuilderImpl(
|
||||
.apply {
|
||||
if (!isUserVerified) {
|
||||
setBiometricPromptDataIfSupported(
|
||||
cipher = biometricsEncryptionManager
|
||||
.getOrCreateCipher(userId),
|
||||
cipher = authRepository.getOrCreateCipher(userId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ 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
|
||||
@@ -54,7 +53,6 @@ object CredentialProviderModule {
|
||||
bitwardenCredentialManager: BitwardenCredentialManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
pendingIntentManager: CredentialManagerPendingIntentManager,
|
||||
biometricsEncryptionManager: BiometricsEncryptionManager,
|
||||
clock: Clock,
|
||||
): CredentialProviderProcessor =
|
||||
CredentialProviderProcessorImpl(
|
||||
@@ -63,7 +61,6 @@ object CredentialProviderModule {
|
||||
bitwardenCredentialManager = bitwardenCredentialManager,
|
||||
pendingIntentManager = pendingIntentManager,
|
||||
clock = clock,
|
||||
biometricsEncryptionManager = biometricsEncryptionManager,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
|
||||
@@ -108,11 +105,11 @@ object CredentialProviderModule {
|
||||
fun provideCredentialEntryBuilder(
|
||||
@ApplicationContext context: Context,
|
||||
pendingIntentManager: CredentialManagerPendingIntentManager,
|
||||
biometricsEncryptionManager: BiometricsEncryptionManager,
|
||||
authRepository: AuthRepository,
|
||||
): CredentialEntryBuilder = CredentialEntryBuilderImpl(
|
||||
context = context,
|
||||
pendingIntentManager = pendingIntentManager,
|
||||
biometricsEncryptionManager = biometricsEncryptionManager,
|
||||
authRepository = authRepository,
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
@@ -41,13 +40,7 @@ class OriginManagerImpl(
|
||||
): ValidateOriginResult {
|
||||
return digitalAssetLinkService
|
||||
.checkDigitalAssetLinksRelations(
|
||||
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(),
|
||||
sourceWebSite = relyingPartyId.prefixHttpsIfNecessary(),
|
||||
targetPackageName = callingAppInfo.packageName,
|
||||
targetCertificateFingerprint = callingAppInfo
|
||||
.getSignatureFingerprintAsHexString()
|
||||
@@ -56,6 +49,7 @@ class OriginManagerImpl(
|
||||
)
|
||||
.fold(
|
||||
onSuccess = {
|
||||
Timber.d("Digital asset link validation result: linked = ${it.linked}")
|
||||
if (it.linked) {
|
||||
ValidateOriginResult.Success(null)
|
||||
} else {
|
||||
@@ -63,6 +57,7 @@ class OriginManagerImpl(
|
||||
}
|
||||
},
|
||||
onFailure = {
|
||||
Timber.e("Failed to validate origin for calling app")
|
||||
ValidateOriginResult.Error.AssetLinkNotFound
|
||||
},
|
||||
)
|
||||
@@ -112,7 +107,7 @@ class OriginManagerImpl(
|
||||
.fold(
|
||||
onSuccess = { it },
|
||||
onFailure = {
|
||||
Timber.e(it, "Failed to validate privileged app: ${callingAppInfo.packageName}")
|
||||
Timber.e(it, "Failed to validate calling app is privileged.")
|
||||
ValidateOriginResult.Error.Unknown
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,7 +52,6 @@ class CredentialProviderProcessorImpl(
|
||||
private val bitwardenCredentialManager: BitwardenCredentialManager,
|
||||
private val pendingIntentManager: CredentialManagerPendingIntentManager,
|
||||
private val clock: Clock,
|
||||
private val biometricsEncryptionManager: BiometricsEncryptionManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
) : CredentialProviderProcessor {
|
||||
|
||||
@@ -63,8 +62,10 @@ 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
|
||||
}
|
||||
@@ -72,12 +73,16 @@ class CredentialProviderProcessorImpl(
|
||||
val createCredentialJob = ioScope.launch {
|
||||
(handleCreatePasskeyQuery(request) ?: handleCreatePasswordQuery(request))
|
||||
?.let { callback.onResult(it) }
|
||||
?: callback.onError(CreateCredentialUnknownException())
|
||||
?: run {
|
||||
Timber.w("Unknown create credential request.")
|
||||
callback.onError(CreateCredentialUnknownException())
|
||||
}
|
||||
}
|
||||
cancellationSignal.setOnCancelListener {
|
||||
if (createCredentialJob.isActive) {
|
||||
createCredentialJob.cancel()
|
||||
}
|
||||
Timber.d("Create credential request cancelled by system.")
|
||||
callback.onError(CreateCredentialCancellationException())
|
||||
}
|
||||
}
|
||||
@@ -87,15 +92,18 @@ 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(
|
||||
@@ -120,10 +128,17 @@ class CredentialProviderProcessorImpl(
|
||||
BeginGetCredentialRequest.asBundle(request),
|
||||
),
|
||||
)
|
||||
.onSuccess { callback.onResult(BeginGetCredentialResponse(credentialEntries = it)) }
|
||||
.onFailure { callback.onError(GetCredentialUnknownException(it.message)) }
|
||||
.onSuccess {
|
||||
Timber.d("Credentials retrieved.")
|
||||
callback.onResult(BeginGetCredentialResponse(credentialEntries = it))
|
||||
}
|
||||
.onFailure {
|
||||
Timber.w("Error getting credentials.")
|
||||
callback.onError(GetCredentialUnknownException(it.message))
|
||||
}
|
||||
}
|
||||
cancellationSignal.setOnCancelListener {
|
||||
Timber.d("Get credential request cancelled by system.")
|
||||
callback.onError(GetCredentialCancellationException())
|
||||
getCredentialJob.cancel()
|
||||
}
|
||||
@@ -135,6 +150,7 @@ class CredentialProviderProcessorImpl(
|
||||
callback: OutcomeReceiver<Void?, ClearCredentialException>,
|
||||
) {
|
||||
// no-op: RFU
|
||||
Timber.w("Unsupported clear credential state request received.")
|
||||
callback.onError(ClearCredentialUnsupportedException())
|
||||
}
|
||||
|
||||
@@ -185,7 +201,7 @@ class CredentialProviderProcessorImpl(
|
||||
.setAutoSelectAllowed(true)
|
||||
|
||||
if (isVaultUnlocked) {
|
||||
biometricsEncryptionManager
|
||||
authRepository
|
||||
.getOrCreateCipher(userId)
|
||||
?.let { entryBuilder.setBiometricPromptDataIfSupported(cipher = it) }
|
||||
}
|
||||
@@ -233,7 +249,7 @@ class CredentialProviderProcessorImpl(
|
||||
.setAutoSelectAllowed(true)
|
||||
|
||||
if (isVaultUnlocked) {
|
||||
biometricsEncryptionManager
|
||||
authRepository
|
||||
.getOrCreateCipher(userId)
|
||||
?.let { entryBuilder.setBiometricPromptDataIfSupported(cipher = it) }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
interface SettingsDiskSource : FlightRecorderDiskSource {
|
||||
|
||||
/**
|
||||
* The currently persisted app language (or `null` if not set).
|
||||
@@ -95,16 +95,6 @@ interface SettingsDiskSource {
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -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.model.FlightRecorderDataSet
|
||||
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
|
||||
|
||||
@@ -32,7 +32,6 @@ interface BiometricsEncryptionManager {
|
||||
*/
|
||||
fun isBiometricIntegrityValid(
|
||||
userId: String,
|
||||
cipher: Cipher?,
|
||||
): Boolean
|
||||
|
||||
/**
|
||||
|
||||
@@ -90,8 +90,8 @@ class BiometricsEncryptionManagerImpl(
|
||||
return cipher?.takeIf { isCipherInitialized }
|
||||
}
|
||||
|
||||
override fun isBiometricIntegrityValid(userId: String, cipher: Cipher?): Boolean =
|
||||
isSystemBiometricIntegrityValid(userId, cipher) && isAccountBiometricIntegrityValid(userId)
|
||||
override fun isBiometricIntegrityValid(userId: String): Boolean =
|
||||
isSystemBiometricIntegrityValid(userId) && isAccountBiometricIntegrityValid(userId)
|
||||
|
||||
override fun isAccountBiometricIntegrityValid(userId: String): Boolean {
|
||||
val systemBioIntegrityState = settingsDiskSource
|
||||
@@ -203,11 +203,13 @@ class BiometricsEncryptionManagerImpl(
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the keystore key and decrypts it using the user-provided [cipher].
|
||||
* Validates the keystore key and decrypts it, if decryption is successful `true` is returned,
|
||||
* `false` otherwise.
|
||||
*/
|
||||
private fun isSystemBiometricIntegrityValid(userId: String, cipher: Cipher?): Boolean {
|
||||
private fun isSystemBiometricIntegrityValid(userId: String): 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,4 +25,10 @@ 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,6 +66,13 @@ class PolicyManagerImpl(
|
||||
)
|
||||
.orEmpty()
|
||||
|
||||
override fun getPersonalOwnershipPolicyOrganizationId(): String? =
|
||||
this
|
||||
.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
.sortedBy { it.revisionDate }
|
||||
.firstOrNull()
|
||||
?.organizationId
|
||||
|
||||
/**
|
||||
* A helper method to filter policies.
|
||||
*/
|
||||
|
||||
@@ -34,6 +34,8 @@ 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
|
||||
@@ -134,7 +136,6 @@ 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) {
|
||||
@@ -179,11 +180,13 @@ class PushManagerImpl @Inject constructor(
|
||||
.decodeFromString<NotificationPayload.SyncCipherNotification>(
|
||||
string = notification.payload,
|
||||
)
|
||||
.takeIf { isLoggedIn(userId) && it.userMatchesNotification(userId) }
|
||||
?.takeIf { it.cipherId != null && it.revisionDate != null }
|
||||
.takeIf {
|
||||
it.cipherId != null && it.revisionDate != null && isLoggedIn(it.userId)
|
||||
}
|
||||
?.let {
|
||||
mutableSyncCipherUpsertSharedFlow.tryEmit(
|
||||
SyncCipherUpsertData(
|
||||
userId = requireNotNull(it.userId),
|
||||
cipherId = requireNotNull(it.cipherId),
|
||||
revisionDate = requireNotNull(it.revisionDate),
|
||||
organizationId = it.organizationId,
|
||||
@@ -228,11 +231,13 @@ class PushManagerImpl @Inject constructor(
|
||||
.decodeFromString<NotificationPayload.SyncFolderNotification>(
|
||||
string = notification.payload,
|
||||
)
|
||||
.takeIf { isLoggedIn(userId) && it.userMatchesNotification(userId) }
|
||||
?.takeIf { it.folderId != null && it.revisionDate != null }
|
||||
.takeIf {
|
||||
it.folderId != null && it.revisionDate != null && isLoggedIn(it.userId)
|
||||
}
|
||||
?.let {
|
||||
mutableSyncFolderUpsertSharedFlow.tryEmit(
|
||||
SyncFolderUpsertData(
|
||||
userId = requireNotNull(it.userId),
|
||||
folderId = requireNotNull(it.folderId),
|
||||
revisionDate = requireNotNull(it.revisionDate),
|
||||
isUpdate = type == NotificationType.SYNC_FOLDER_UPDATE,
|
||||
@@ -273,11 +278,13 @@ class PushManagerImpl @Inject constructor(
|
||||
.decodeFromString<NotificationPayload.SyncSendNotification>(
|
||||
string = notification.payload,
|
||||
)
|
||||
.takeIf { isLoggedIn(userId) && it.userMatchesNotification(userId) }
|
||||
?.takeIf { it.sendId != null && it.revisionDate != null }
|
||||
.takeIf {
|
||||
it.sendId != null && it.revisionDate != null && isLoggedIn(it.userId)
|
||||
}
|
||||
?.let {
|
||||
mutableSyncSendUpsertSharedFlow.tryEmit(
|
||||
SyncSendUpsertData(
|
||||
userId = requireNotNull(it.userId),
|
||||
sendId = requireNotNull(it.sendId),
|
||||
revisionDate = requireNotNull(it.revisionDate),
|
||||
isUpdate = type == NotificationType.SYNC_SEND_UPDATE,
|
||||
@@ -361,11 +368,11 @@ class PushManagerImpl @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
private fun isLoggedIn(
|
||||
userId: String,
|
||||
): Boolean = authDiskSource.getAccountTokens(userId)?.isLoggedIn == true
|
||||
}
|
||||
|
||||
private fun NotificationPayload.userMatchesNotification(userId: String): Boolean {
|
||||
return this.userId != null && this.userId == userId
|
||||
userId: String?,
|
||||
): Boolean {
|
||||
contract { returns(true) implies (userId != null) }
|
||||
return userId?.let { authDiskSource.getAccountTokens(it) }?.isLoggedIn == true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import com.bitwarden.core.data.manager.toast.ToastManagerImpl
|
||||
import com.bitwarden.cxf.registry.CredentialExchangeRegistry
|
||||
import com.bitwarden.cxf.registry.dsl.credentialExchangeRegistry
|
||||
import com.bitwarden.data.manager.NativeLibraryManager
|
||||
import com.bitwarden.data.manager.flightrecorder.FlightRecorderManager
|
||||
import com.bitwarden.data.manager.flightrecorder.FlightRecorderWriter
|
||||
import com.bitwarden.data.repository.ServerConfigRepository
|
||||
import com.bitwarden.network.BitwardenServiceClient
|
||||
import com.bitwarden.network.service.EventService
|
||||
@@ -63,10 +65,6 @@ 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
|
||||
@@ -84,7 +82,6 @@ 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
|
||||
@@ -109,18 +106,6 @@ 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(
|
||||
@@ -129,11 +114,11 @@ object PlatformManagerModule {
|
||||
dispatcherManager: DispatcherManager,
|
||||
settingsDiskSource: SettingsDiskSource,
|
||||
flightRecorderWriter: FlightRecorderWriter,
|
||||
): FlightRecorderManager = FlightRecorderManagerImpl(
|
||||
): FlightRecorderManager = FlightRecorderManager.create(
|
||||
context = context,
|
||||
clock = clock,
|
||||
dispatcherManager = dispatcherManager,
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
flightRecorderDiskSource = settingsDiskSource,
|
||||
flightRecorderWriter = flightRecorderWriter,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.flightrecorder
|
||||
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.FlightRecorderDuration
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Manager class that handles recording logs for the flight recorder.
|
||||
*/
|
||||
interface FlightRecorderManager {
|
||||
/**
|
||||
* Returns a set of all flight recorder data currently stored on the device.
|
||||
*/
|
||||
val flightRecorderData: FlightRecorderDataSet
|
||||
|
||||
/**
|
||||
* Tracks changes to [FlightRecorderDataSet].
|
||||
*/
|
||||
val flightRecorderDataFlow: StateFlow<FlightRecorderDataSet>
|
||||
|
||||
/**
|
||||
* Dismisses the all flight recorder banners.
|
||||
*/
|
||||
fun dismissFlightRecorderBanner()
|
||||
|
||||
/**
|
||||
* Starts the flight recorder for the given [duration].
|
||||
*/
|
||||
fun startFlightRecorder(duration: FlightRecorderDuration)
|
||||
|
||||
/**
|
||||
* Cancels the active flight recorder if one is currently active.
|
||||
*/
|
||||
fun endFlightRecorder()
|
||||
|
||||
/**
|
||||
* Deletes the raw log file and metadata associated with the [data].
|
||||
*/
|
||||
fun deleteLog(data: FlightRecorderDataSet.FlightRecorderData)
|
||||
|
||||
/**
|
||||
* Deletes the raw log files and metadata.
|
||||
*/
|
||||
fun deleteAllLogs()
|
||||
}
|
||||
@@ -114,4 +114,24 @@ sealed class OrganizationEvent {
|
||||
override val type: OrganizationEventType
|
||||
get() = OrganizationEventType.USER_CLIENT_EXPORTED_VAULT
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks when a user's personal ciphers have been migrated to their organization's My Items
|
||||
* folder as required by the organization's personal vault ownership policy.
|
||||
*/
|
||||
data object ItemOrganizationAccepted : OrganizationEvent() {
|
||||
override val cipherId: String? = null
|
||||
override val type: OrganizationEventType
|
||||
get() = OrganizationEventType.ORGANIZATION_ITEM_ORGANIZATION_ACCEPTED
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks when a user chooses to leave an organization instead of migrating their personal
|
||||
* ciphers to their organization's My Items folder.
|
||||
*/
|
||||
data object ItemOrganizationDeclined : OrganizationEvent() {
|
||||
override val cipherId: String? = null
|
||||
override val type: OrganizationEventType
|
||||
get() = OrganizationEventType.ORGANIZATION_ITEM_ORGANIZATION_DECLINED
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ import java.time.ZonedDateTime
|
||||
/**
|
||||
* Required data for sync cipher upsert operations.
|
||||
*
|
||||
* @property userId The user ID associated with this update.
|
||||
* @property cipherId The cipher ID.
|
||||
* @property revisionDate The cipher's revision date. This is used to determine if the local copy of
|
||||
* the cipher is out-of-date.
|
||||
* @property isUpdate Whether or not this is an update of an existing cipher.
|
||||
*/
|
||||
data class SyncCipherUpsertData(
|
||||
val userId: String,
|
||||
val cipherId: String,
|
||||
val revisionDate: ZonedDateTime,
|
||||
val organizationId: String?,
|
||||
|
||||
@@ -5,12 +5,14 @@ import java.time.ZonedDateTime
|
||||
/**
|
||||
* Required data for sync folder upsert operations.
|
||||
*
|
||||
* @property userId The user ID associated with this update.
|
||||
* @property folderId The folder ID.
|
||||
* @property revisionDate The folder's revision date. This is used to determine if the local copy of
|
||||
* the folder is out-of-date.
|
||||
* @property isUpdate Whether or not this is an update of an existing folder.
|
||||
*/
|
||||
data class SyncFolderUpsertData(
|
||||
val userId: String,
|
||||
val folderId: String,
|
||||
val revisionDate: ZonedDateTime,
|
||||
val isUpdate: Boolean,
|
||||
|
||||
@@ -5,12 +5,14 @@ import java.time.ZonedDateTime
|
||||
/**
|
||||
* Required data for sync send upsert operations.
|
||||
*
|
||||
* @property userId The user ID associated with this update.
|
||||
* @property sendId The send ID.
|
||||
* @property revisionDate The send's revision date. This is used to determine if the local copy of
|
||||
* the send is out-of-date.
|
||||
* @property isUpdate Whether or not this is an update of an existing send.
|
||||
*/
|
||||
data class SyncSendUpsertData(
|
||||
val userId: String,
|
||||
val sendId: String,
|
||||
val revisionDate: ZonedDateTime,
|
||||
val isUpdate: Boolean,
|
||||
|
||||
@@ -4,13 +4,13 @@ import com.bitwarden.authenticatorbridge.model.SharedAccountData
|
||||
import com.bitwarden.core.InitOrgCryptoRequest
|
||||
import com.bitwarden.core.InitUserCryptoMethod
|
||||
import com.bitwarden.core.InitUserCryptoRequest
|
||||
import com.bitwarden.core.data.repository.error.MissingPropertyException
|
||||
import com.bitwarden.core.data.util.asSuccess
|
||||
import com.bitwarden.core.data.util.flatMap
|
||||
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
|
||||
import com.x8bit.bitwarden.data.platform.error.MissingPropertyException
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.sanitizeTotpUri
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.ScopedVaultSdkSource
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.x8bit.bitwarden.data.platform.repository
|
||||
|
||||
import com.bitwarden.data.manager.flightrecorder.FlightRecorderManager
|
||||
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
|
||||
import com.x8bit.bitwarden.data.platform.manager.flightrecorder.FlightRecorderManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.platform.repository
|
||||
import android.view.autofill.AutofillManager
|
||||
import com.bitwarden.authenticatorbridge.util.generateSecretKey
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.data.manager.flightrecorder.FlightRecorderManager
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.network.model.SyncResponseJson
|
||||
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
|
||||
@@ -16,7 +17,6 @@ import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.flightrecorder.FlightRecorderManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.repository.di
|
||||
|
||||
import android.view.autofill.AutofillManager
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.data.manager.flightrecorder.FlightRecorderManager
|
||||
import com.bitwarden.data.repository.ServerConfigRepository
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager
|
||||
@@ -10,7 +11,6 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.flightrecorder.FlightRecorderManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.AuthenticatorBridgeRepository
|
||||
import com.x8bit.bitwarden.data.platform.repository.AuthenticatorBridgeRepositoryImpl
|
||||
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
|
||||
|
||||
@@ -6,6 +6,8 @@ import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.core.data.util.asFailure
|
||||
import com.bitwarden.core.data.util.asSuccess
|
||||
import com.bitwarden.core.data.util.flatMap
|
||||
import com.bitwarden.data.manager.file.FileManager
|
||||
import com.bitwarden.data.manager.model.DownloadResult
|
||||
import com.bitwarden.network.model.AttachmentJsonResponse
|
||||
import com.bitwarden.network.model.CreateCipherInOrganizationJsonRequest
|
||||
import com.bitwarden.network.model.CreateCipherResponseJson
|
||||
@@ -17,6 +19,7 @@ import com.bitwarden.vault.AttachmentView
|
||||
import com.bitwarden.vault.CipherView
|
||||
import com.bitwarden.vault.EncryptionContext
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
|
||||
import com.x8bit.bitwarden.data.platform.manager.PushManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
|
||||
@@ -24,7 +27,6 @@ import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherDeleteData
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.DownloadResult
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.CreateAttachmentResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
|
||||
@@ -53,6 +55,7 @@ import java.time.Clock
|
||||
class CipherManagerImpl(
|
||||
private val fileManager: FileManager,
|
||||
private val authDiskSource: AuthDiskSource,
|
||||
private val settingsDiskSource: SettingsDiskSource,
|
||||
private val ciphersService: CiphersService,
|
||||
private val vaultDiskSource: VaultDiskSource,
|
||||
private val vaultSdkSource: VaultSdkSource,
|
||||
@@ -689,7 +692,7 @@ class CipherManagerImpl(
|
||||
* for now.
|
||||
*/
|
||||
private suspend fun syncCipherIfNecessary(syncCipherUpsertData: SyncCipherUpsertData) {
|
||||
val userId = activeUserId ?: return
|
||||
val userId = syncCipherUpsertData.userId
|
||||
val cipherId = syncCipherUpsertData.cipherId
|
||||
val organizationId = syncCipherUpsertData.organizationId
|
||||
val collectionIds = syncCipherUpsertData.collectionIds
|
||||
@@ -732,6 +735,12 @@ class CipherManagerImpl(
|
||||
}
|
||||
|
||||
if (!shouldUpdate) return
|
||||
if (activeUserId != userId) {
|
||||
// We cannot update right now since the accounts do not match, so we will
|
||||
// do a full-sync on the next check.
|
||||
settingsDiskSource.storeLastSyncTime(userId = userId, lastSyncTime = null)
|
||||
return
|
||||
}
|
||||
|
||||
ciphersService
|
||||
.getCipher(cipherId = cipherId)
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.bitwarden.network.model.UpdateFolderResponseJson
|
||||
import com.bitwarden.network.service.FolderService
|
||||
import com.bitwarden.vault.FolderView
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
|
||||
import com.x8bit.bitwarden.data.platform.manager.PushManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderDeleteData
|
||||
@@ -25,8 +26,10 @@ import kotlinx.coroutines.flow.onEach
|
||||
/**
|
||||
* The default implementation of the [FolderManager].
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
class FolderManagerImpl(
|
||||
private val authDiskSource: AuthDiskSource,
|
||||
private val settingsDiskSource: SettingsDiskSource,
|
||||
private val folderService: FolderService,
|
||||
private val vaultDiskSource: VaultDiskSource,
|
||||
private val vaultSdkSource: VaultSdkSource,
|
||||
@@ -148,7 +151,7 @@ class FolderManagerImpl(
|
||||
* are met.
|
||||
*/
|
||||
private suspend fun syncFolderIfNecessary(syncFolderUpsertData: SyncFolderUpsertData) {
|
||||
val userId = activeUserId ?: return
|
||||
val userId = syncFolderUpsertData.userId
|
||||
val folderId = syncFolderUpsertData.folderId
|
||||
val isUpdate = syncFolderUpsertData.isUpdate
|
||||
val revisionDate = syncFolderUpsertData.revisionDate
|
||||
@@ -162,6 +165,12 @@ class FolderManagerImpl(
|
||||
localFolder.revisionDate.toEpochSecond() < revisionDate.toEpochSecond()
|
||||
|
||||
if (!isValidCreate && !isValidUpdate) return
|
||||
if (activeUserId != userId) {
|
||||
// We cannot update right now since the accounts do not match, so we will
|
||||
// do a full-sync on the next check.
|
||||
settingsDiskSource.storeLastSyncTime(userId = userId, lastSyncTime = null)
|
||||
return
|
||||
}
|
||||
|
||||
folderService
|
||||
.getFolder(folderId = folderId)
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.core.data.util.asFailure
|
||||
import com.bitwarden.core.data.util.asSuccess
|
||||
import com.bitwarden.core.data.util.flatMap
|
||||
import com.bitwarden.data.manager.file.FileManager
|
||||
import com.bitwarden.network.model.CreateFileSendResponse
|
||||
import com.bitwarden.network.model.CreateSendJsonResponse
|
||||
import com.bitwarden.network.model.UpdateSendResponseJson
|
||||
@@ -13,6 +14,7 @@ import com.bitwarden.send.Send
|
||||
import com.bitwarden.send.SendType
|
||||
import com.bitwarden.send.SendView
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
|
||||
import com.x8bit.bitwarden.data.platform.manager.PushManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
|
||||
@@ -38,6 +40,7 @@ import retrofit2.HttpException
|
||||
@Suppress("LongParameterList")
|
||||
class SendManagerImpl(
|
||||
private val authDiskSource: AuthDiskSource,
|
||||
private val settingsDiskSource: SettingsDiskSource,
|
||||
private val vaultDiskSource: VaultDiskSource,
|
||||
private val vaultSdkSource: VaultSdkSource,
|
||||
private val sendsService: SendsService,
|
||||
@@ -265,7 +268,7 @@ class SendManagerImpl(
|
||||
* now.
|
||||
*/
|
||||
private suspend fun syncSendIfNecessary(syncSendUpsertData: SyncSendUpsertData) {
|
||||
val userId = activeUserId ?: return
|
||||
val userId = syncSendUpsertData.userId
|
||||
val sendId = syncSendUpsertData.sendId
|
||||
val isUpdate = syncSendUpsertData.isUpdate
|
||||
val revisionDate = syncSendUpsertData.revisionDate
|
||||
@@ -278,6 +281,12 @@ class SendManagerImpl(
|
||||
localSend != null &&
|
||||
localSend.revisionDate.toEpochSecond() < revisionDate.toEpochSecond()
|
||||
if (!isValidCreate && !isValidUpdate) return
|
||||
if (activeUserId != userId) {
|
||||
// We cannot update right now since the accounts do not match, so we will
|
||||
// do a full-sync on the next check.
|
||||
settingsDiskSource.storeLastSyncTime(userId = userId, lastSyncTime = null)
|
||||
return
|
||||
}
|
||||
|
||||
sendsService
|
||||
.getSend(sendId = sendId)
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.bitwarden.core.InitUserCryptoMethod
|
||||
import com.bitwarden.core.InitUserCryptoRequest
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.core.data.manager.realtime.RealtimeManager
|
||||
import com.bitwarden.core.data.repository.error.MissingPropertyException
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.core.data.util.asSuccess
|
||||
import com.bitwarden.core.data.util.concurrentMapOf
|
||||
@@ -26,7 +27,6 @@ import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokens
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
|
||||
import com.x8bit.bitwarden.data.platform.error.MissingPropertyException
|
||||
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
|
||||
import com.x8bit.bitwarden.data.platform.manager.AppStateManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.AppCreationState
|
||||
@@ -259,29 +259,19 @@ class VaultLockManagerImpl(
|
||||
kdf: Kdf,
|
||||
userId: String,
|
||||
) {
|
||||
if (initUserCryptoMethod is InitUserCryptoMethod.Password ||
|
||||
initUserCryptoMethod is InitUserCryptoMethod.MasterPasswordUnlock
|
||||
) {
|
||||
val password = when (initUserCryptoMethod) {
|
||||
is InitUserCryptoMethod.Password -> initUserCryptoMethod.password
|
||||
is InitUserCryptoMethod.MasterPasswordUnlock -> initUserCryptoMethod.password
|
||||
else -> throw IllegalStateException(
|
||||
"Invalid initUserCryptoMethod ${initUserCryptoMethod.logTag}.",
|
||||
)
|
||||
}
|
||||
|
||||
(initUserCryptoMethod as? InitUserCryptoMethod.MasterPasswordUnlock)?.let {
|
||||
// Save the master password hash.
|
||||
authSdkSource
|
||||
.hashPassword(
|
||||
email = email,
|
||||
password = password,
|
||||
password = initUserCryptoMethod.password,
|
||||
kdf = kdf,
|
||||
purpose = HashPurpose.LOCAL_AUTHORIZATION,
|
||||
)
|
||||
.onSuccess { passwordHash ->
|
||||
.onSuccess {
|
||||
authDiskSource.storeMasterPasswordHash(
|
||||
userId = userId,
|
||||
passwordHash = passwordHash,
|
||||
passwordHash = it,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -713,15 +703,15 @@ class VaultLockManagerImpl(
|
||||
|
||||
private suspend fun updateKdfIfNeeded(initUserCryptoMethod: InitUserCryptoMethod) {
|
||||
val password = when (initUserCryptoMethod) {
|
||||
is InitUserCryptoMethod.Password -> initUserCryptoMethod.password
|
||||
is InitUserCryptoMethod.MasterPasswordUnlock -> initUserCryptoMethod.password
|
||||
is InitUserCryptoMethod.AuthRequest,
|
||||
is InitUserCryptoMethod.DecryptedKey,
|
||||
is InitUserCryptoMethod.DeviceKey,
|
||||
is InitUserCryptoMethod.KeyConnector,
|
||||
is InitUserCryptoMethod.Password,
|
||||
is InitUserCryptoMethod.Pin,
|
||||
is InitUserCryptoMethod.PinEnvelope,
|
||||
-> return
|
||||
-> return
|
||||
}
|
||||
|
||||
kdfManager
|
||||
|
||||
@@ -310,7 +310,7 @@ class VaultSyncManagerImpl(
|
||||
localSecurityStamp?.let {
|
||||
if (serverSecurityStamp != localSecurityStamp) {
|
||||
// Ensure UserLogoutManager is available
|
||||
userLogoutManager.softLogout(
|
||||
userLogoutManager.logout(
|
||||
userId = userId,
|
||||
reason = LogoutReason.SecurityStamp,
|
||||
)
|
||||
|
||||
@@ -3,8 +3,8 @@ package com.x8bit.bitwarden.data.vault.manager.di
|
||||
import android.content.Context
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.core.data.manager.realtime.RealtimeManager
|
||||
import com.bitwarden.data.manager.file.FileManager
|
||||
import com.bitwarden.network.service.CiphersService
|
||||
import com.bitwarden.network.service.DownloadService
|
||||
import com.bitwarden.network.service.FolderService
|
||||
import com.bitwarden.network.service.SendsService
|
||||
import com.bitwarden.network.service.SyncService
|
||||
@@ -27,8 +27,6 @@ import com.x8bit.bitwarden.data.vault.manager.CipherManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.CipherManagerImpl
|
||||
import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManagerImpl
|
||||
import com.x8bit.bitwarden.data.vault.manager.FileManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.FileManagerImpl
|
||||
import com.x8bit.bitwarden.data.vault.manager.FolderManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.FolderManagerImpl
|
||||
import com.x8bit.bitwarden.data.vault.manager.PinProtectedUserKeyManager
|
||||
@@ -61,6 +59,7 @@ object VaultManagerModule {
|
||||
@Singleton
|
||||
fun provideCipherManager(
|
||||
ciphersService: CiphersService,
|
||||
settingsDiskSource: SettingsDiskSource,
|
||||
vaultDiskSource: VaultDiskSource,
|
||||
vaultSdkSource: VaultSdkSource,
|
||||
authDiskSource: AuthDiskSource,
|
||||
@@ -71,6 +70,7 @@ object VaultManagerModule {
|
||||
pushManager: PushManager,
|
||||
): CipherManager = CipherManagerImpl(
|
||||
fileManager = fileManager,
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
authDiskSource = authDiskSource,
|
||||
ciphersService = ciphersService,
|
||||
vaultDiskSource = vaultDiskSource,
|
||||
@@ -85,6 +85,7 @@ object VaultManagerModule {
|
||||
@Singleton
|
||||
fun provideFolderManager(
|
||||
folderService: FolderService,
|
||||
settingsDiskSource: SettingsDiskSource,
|
||||
vaultDiskSource: VaultDiskSource,
|
||||
vaultSdkSource: VaultSdkSource,
|
||||
authDiskSource: AuthDiskSource,
|
||||
@@ -92,6 +93,7 @@ object VaultManagerModule {
|
||||
pushManager: PushManager,
|
||||
): FolderManager = FolderManagerImpl(
|
||||
authDiskSource = authDiskSource,
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
folderService = folderService,
|
||||
vaultDiskSource = vaultDiskSource,
|
||||
vaultSdkSource = vaultSdkSource,
|
||||
@@ -106,6 +108,7 @@ object VaultManagerModule {
|
||||
vaultDiskSource: VaultDiskSource,
|
||||
vaultSdkSource: VaultSdkSource,
|
||||
authDiskSource: AuthDiskSource,
|
||||
settingsDiskSource: SettingsDiskSource,
|
||||
fileManager: FileManager,
|
||||
reviewPromptManager: ReviewPromptManager,
|
||||
pushManager: PushManager,
|
||||
@@ -113,6 +116,7 @@ object VaultManagerModule {
|
||||
): SendManager = SendManagerImpl(
|
||||
fileManager = fileManager,
|
||||
authDiskSource = authDiskSource,
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
sendsService = sendsService,
|
||||
vaultDiskSource = vaultDiskSource,
|
||||
vaultSdkSource = vaultSdkSource,
|
||||
@@ -121,18 +125,6 @@ object VaultManagerModule {
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideFileManager(
|
||||
@ApplicationContext context: Context,
|
||||
downloadService: DownloadService,
|
||||
dispatcherManager: DispatcherManager,
|
||||
): FileManager = FileManagerImpl(
|
||||
context = context,
|
||||
downloadService = downloadService,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideVaultLockManager(
|
||||
|
||||
@@ -166,4 +166,11 @@ interface VaultRepository :
|
||||
* `null` if the item cannot be found.
|
||||
*/
|
||||
fun getVaultListItemStateFlow(itemId: String): StateFlow<DataState<CipherListView?>>
|
||||
|
||||
/**
|
||||
* Checks if there are any personal vault items (items without an organization ID) in the vault.
|
||||
*
|
||||
* @return `true` if there are personal vault items, `false` otherwise.
|
||||
*/
|
||||
fun hasPersonalVaultItems(): Boolean
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.vault.repository
|
||||
import com.bitwarden.core.DateTime
|
||||
import com.bitwarden.core.InitUserCryptoMethod
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.core.data.repository.error.MissingPropertyException
|
||||
import com.bitwarden.core.data.repository.model.DataState
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.core.data.repository.util.combineDataStates
|
||||
@@ -21,7 +22,6 @@ import com.bitwarden.vault.FolderView
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
|
||||
import com.x8bit.bitwarden.data.autofill.util.login
|
||||
import com.x8bit.bitwarden.data.platform.error.MissingPropertyException
|
||||
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||
@@ -341,25 +341,19 @@ class VaultRepositoryImpl(
|
||||
): VaultUnlockResult {
|
||||
val userId = activeUserId
|
||||
?: return VaultUnlockResult.InvalidStateError(error = NoActiveUserException())
|
||||
val userKey = authDiskSource.getUserKey(userId = userId)
|
||||
?: return VaultUnlockResult.InvalidStateError(
|
||||
error = MissingPropertyException("User key"),
|
||||
)
|
||||
|
||||
val activeAccount = authDiskSource.userState?.activeAccount
|
||||
val initUserCryptoMethod = activeAccount
|
||||
val masterPasswordUnlock = activeAccount
|
||||
?.profile
|
||||
?.userDecryptionOptions
|
||||
?.masterPasswordUnlock
|
||||
?.let { masterPasswordUnlock ->
|
||||
InitUserCryptoMethod.MasterPasswordUnlock(
|
||||
password = masterPassword,
|
||||
masterPasswordUnlock = masterPasswordUnlock.toSdkMasterPasswordUnlock(),
|
||||
)
|
||||
}
|
||||
?: InitUserCryptoMethod.Password(
|
||||
password = masterPassword,
|
||||
userKey = userKey,
|
||||
?: return VaultUnlockResult.InvalidStateError(
|
||||
error = MissingPropertyException("MasterPasswordUnlock data"),
|
||||
)
|
||||
val initUserCryptoMethod = InitUserCryptoMethod.MasterPasswordUnlock(
|
||||
password = masterPassword,
|
||||
masterPasswordUnlock = masterPasswordUnlock.toSdkMasterPasswordUnlock(),
|
||||
)
|
||||
return this
|
||||
.unlockVaultForUser(
|
||||
userId = userId,
|
||||
@@ -556,4 +550,9 @@ class VaultRepositoryImpl(
|
||||
organizationKeys = organizationKeys,
|
||||
)
|
||||
}
|
||||
|
||||
override fun hasPersonalVaultItems(): Boolean {
|
||||
val vaultData = vaultSyncManager.vaultDataStateFlow.value.data ?: return false
|
||||
return vaultData.decryptCipherListResult.successes.any { it.organizationId.isNullOrEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,12 @@ val InitUserCryptoMethod.logTag: String
|
||||
is InitUserCryptoMethod.DecryptedKey -> "Decrypted Key (Never Lock/Biometrics)"
|
||||
is InitUserCryptoMethod.DeviceKey -> "Device Key"
|
||||
is InitUserCryptoMethod.KeyConnector -> "Key Connector"
|
||||
is InitUserCryptoMethod.Password -> "Password"
|
||||
is InitUserCryptoMethod.Pin -> "Pin"
|
||||
is InitUserCryptoMethod.PinEnvelope -> "Pin Envelope"
|
||||
is InitUserCryptoMethod.MasterPasswordUnlock -> "Master Password Unlock"
|
||||
is InitUserCryptoMethod.Password -> {
|
||||
// PM-27290: InitUserCryptoMethod.Password will be removed from the SDK in a future
|
||||
// release. This else branch can be cleaned up afterwards.
|
||||
throw IllegalArgumentException("Unsupported InitUserCryptoMethod: $this")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
|
||||
@@ -33,17 +32,12 @@ class SetupUnlockViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val authRepository: AuthRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val biometricsEncryptionManager: BiometricsEncryptionManager,
|
||||
private val firstTimeActionManager: FirstTimeActionManager,
|
||||
private val browserThirdPartyAutofillEnabledManager: BrowserThirdPartyAutofillEnabledManager,
|
||||
) : BaseViewModel<SetupUnlockState, SetupUnlockEvent, SetupUnlockAction>(
|
||||
// We load the state from the savedStateHandle for testing purposes.
|
||||
initialState = savedStateHandle[KEY_STATE] ?: run {
|
||||
val userId = requireNotNull(authRepository.userStateFlow.value).activeUserId
|
||||
val isBiometricsValid = biometricsEncryptionManager.isBiometricIntegrityValid(
|
||||
userId = userId,
|
||||
cipher = biometricsEncryptionManager.getOrCreateCipher(userId = userId),
|
||||
)
|
||||
// whether or not the user has completed the initial setup prior to this.
|
||||
val isInitialSetup = savedStateHandle.toSetupUnlockArgs().isInitialSetup
|
||||
SetupUnlockState(
|
||||
@@ -55,7 +49,7 @@ class SetupUnlockViewModel @Inject constructor(
|
||||
?.hasMasterPassword != false,
|
||||
isUnlockWithPinEnabled = settingsRepository.isUnlockWithPinEnabled,
|
||||
isUnlockWithBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled &&
|
||||
isBiometricsValid,
|
||||
authRepository.isBiometricIntegrityValid(userId = userId),
|
||||
dialogState = null,
|
||||
isInitialSetup = isInitialSetup,
|
||||
)
|
||||
@@ -100,7 +94,7 @@ class SetupUnlockViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun handleEnableBiometricsClick() {
|
||||
biometricsEncryptionManager
|
||||
authRepository
|
||||
.createCipherOrNull(userId = state.userId)
|
||||
?.let {
|
||||
sendEvent(
|
||||
@@ -134,7 +128,7 @@ class SetupUnlockViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun handleUnlockWithBiometricToggleDisabled() {
|
||||
biometricsEncryptionManager.clearBiometrics(userId = state.userId)
|
||||
authRepository.clearBiometrics(userId = state.userId)
|
||||
mutableStateFlow.update { it.copy(isUnlockWithBiometricsEnabled = false) }
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.auth.feature.enterprisesignon
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.data.repository.util.baseIdentityUrl
|
||||
@@ -400,7 +401,7 @@ class EnterpriseSignOnViewModel @Inject constructor(
|
||||
|
||||
// Hide any dialog since we're about to launch a custom tab and could return without getting
|
||||
// a result due to user intervention
|
||||
sendAction(EnterpriseSignOnAction.Internal.OnGenerateUriForSsoResult(Uri.parse(uri)))
|
||||
sendAction(EnterpriseSignOnAction.Internal.OnGenerateUriForSsoResult(uri.toUri()))
|
||||
}
|
||||
|
||||
private fun showError(
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.core.net.toUri
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.data.datasource.disk.model.EnvironmentUrlDataJson
|
||||
import com.bitwarden.data.manager.file.FileManager
|
||||
import com.bitwarden.data.repository.model.Environment
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.base.util.isValidUri
|
||||
@@ -21,7 +22,6 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.model.MutualTlsKeyHost
|
||||
import com.x8bit.bitwarden.data.platform.manager.CertificateManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.ImportPrivateKeyResult
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
import com.x8bit.bitwarden.data.vault.manager.FileManager
|
||||
import com.x8bit.bitwarden.ui.platform.manager.keychain.model.PrivateKeyAliasSelectionResult
|
||||
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -20,7 +15,6 @@ import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.testTag
|
||||
@@ -30,17 +24,21 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
|
||||
import com.bitwarden.ui.platform.components.card.BitwardenContentCard
|
||||
import com.bitwarden.ui.platform.components.content.BitwardenLoadingContent
|
||||
import com.bitwarden.ui.platform.components.content.model.ContentBlockData
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
|
||||
import com.bitwarden.ui.platform.components.indicator.BitwardenCircularProgressIndicator
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.text.BitwardenClickableText
|
||||
import com.bitwarden.ui.platform.components.text.BitwardenHyperTextLink
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
/**
|
||||
* The top level composable for the Login with Device screen.
|
||||
@@ -120,111 +118,99 @@ private fun LoginWithDeviceScreenContent(
|
||||
modifier = modifier
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(height = 24.dp))
|
||||
|
||||
Text(
|
||||
text = state.title(),
|
||||
textAlign = TextAlign.Start,
|
||||
style = BitwardenTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
style = BitwardenTheme.typography.titleMedium,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Spacer(modifier = Modifier.height(height = 12.dp))
|
||||
|
||||
Text(
|
||||
text = state.subtitle(),
|
||||
textAlign = TextAlign.Start,
|
||||
textAlign = TextAlign.Center,
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Spacer(modifier = Modifier.height(height = 12.dp))
|
||||
|
||||
Text(
|
||||
text = state.description(),
|
||||
textAlign = TextAlign.Start,
|
||||
textAlign = TextAlign.Center,
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(id = BitwardenString.fingerprint_phrase),
|
||||
textAlign = TextAlign.Start,
|
||||
style = BitwardenTheme.typography.titleLarge,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
BitwardenContentCard(
|
||||
contentItems = persistentListOf(
|
||||
ContentBlockData(
|
||||
headerText = stringResource(id = BitwardenString.fingerprint_phrase),
|
||||
subtitleText = state.fingerprintPhrase,
|
||||
),
|
||||
),
|
||||
contentHeaderTextStyle = BitwardenTheme.typography.titleMedium,
|
||||
contentSubtitleTextStyle = BitwardenTheme.typography.sensitiveInfoSmall,
|
||||
contentSubtitleColor = BitwardenTheme.colorScheme.text.codePink,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = state.fingerprintPhrase,
|
||||
textAlign = TextAlign.Start,
|
||||
color = BitwardenTheme.colorScheme.text.codePink,
|
||||
style = BitwardenTheme.typography.sensitiveInfoSmall,
|
||||
minLines = 2,
|
||||
modifier = Modifier
|
||||
.testTag("FingerprintPhraseValue")
|
||||
.padding(horizontal = 16.dp)
|
||||
.testTag(tag = "FingerprintPhraseValue")
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
if (state.allowsResend) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
Spacer(modifier = Modifier.height(height = 24.dp))
|
||||
BitwardenOutlinedButton(
|
||||
label = stringResource(id = BitwardenString.resend_notification),
|
||||
onClick = onResendNotificationClick,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minHeight = 40.dp)
|
||||
.align(Alignment.Start),
|
||||
) {
|
||||
if (state.isResendNotificationLoading) {
|
||||
BitwardenCircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 64.dp)
|
||||
.size(size = 16.dp),
|
||||
)
|
||||
} else {
|
||||
BitwardenClickableText(
|
||||
modifier = Modifier.testTag("ResendNotificationButton"),
|
||||
label = stringResource(id = BitwardenString.resend_notification),
|
||||
style = BitwardenTheme.typography.labelLarge,
|
||||
innerPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp),
|
||||
onClick = onResendNotificationClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
.testTag(tag = "ResendNotificationButton")
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(28.dp))
|
||||
Spacer(modifier = Modifier.height(height = 24.dp))
|
||||
|
||||
Text(
|
||||
text = state.otherOptions(),
|
||||
textAlign = TextAlign.Start,
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
style = BitwardenTheme.typography.bodySmall,
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
BitwardenClickableText(
|
||||
modifier = Modifier.testTag("ViewAllLoginOptionsButton"),
|
||||
label = stringResource(id = BitwardenString.view_all_login_options),
|
||||
innerPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp),
|
||||
style = BitwardenTheme.typography.labelLarge,
|
||||
Spacer(modifier = Modifier.height(height = 12.dp))
|
||||
|
||||
BitwardenHyperTextLink(
|
||||
annotatedResId = BitwardenString.need_another_option_view_all_login_options,
|
||||
annotationKey = "viewAll",
|
||||
accessibilityString = stringResource(id = BitwardenString.view_all_login_options),
|
||||
onClick = onViewAllLogInOptionsClick,
|
||||
style = BitwardenTheme.typography.bodySmall,
|
||||
modifier = Modifier
|
||||
.testTag(tag = "ViewAllLoginOptionsButton")
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(height = 12.dp))
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ class LoginWithDeviceViewModel @Inject constructor(
|
||||
private var authJob: Job = Job().apply { complete() }
|
||||
|
||||
init {
|
||||
sendNewAuthRequest(isResend = false)
|
||||
sendNewAuthRequest()
|
||||
}
|
||||
|
||||
override fun handleAction(action: LoginWithDeviceAction) {
|
||||
@@ -74,7 +74,14 @@ class LoginWithDeviceViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun handleResendNotificationClicked() {
|
||||
sendNewAuthRequest(isResend = true)
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = LoginWithDeviceState.DialogState.Loading(
|
||||
message = BitwardenString.resending.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
sendNewAuthRequest()
|
||||
}
|
||||
|
||||
private fun handleViewAllLogInOptionsClicked() {
|
||||
@@ -99,9 +106,6 @@ class LoginWithDeviceViewModel @Inject constructor(
|
||||
) {
|
||||
when (val result = action.result) {
|
||||
is CreateAuthRequestResult.Success -> {
|
||||
updateContent { content ->
|
||||
content.copy(isResendNotificationLoading = false)
|
||||
}
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = null,
|
||||
@@ -123,7 +127,6 @@ class LoginWithDeviceViewModel @Inject constructor(
|
||||
viewState = LoginWithDeviceState.ViewState.Content(
|
||||
loginWithDeviceType = it.loginWithDeviceType,
|
||||
fingerprintPhrase = result.authRequest.fingerprint,
|
||||
isResendNotificationLoading = false,
|
||||
),
|
||||
dialogState = null,
|
||||
)
|
||||
@@ -131,9 +134,6 @@ class LoginWithDeviceViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
is CreateAuthRequestResult.Error -> {
|
||||
updateContent { content ->
|
||||
content.copy(isResendNotificationLoading = false)
|
||||
}
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = LoginWithDeviceState.DialogState.Error(
|
||||
@@ -149,9 +149,6 @@ class LoginWithDeviceViewModel @Inject constructor(
|
||||
CreateAuthRequestResult.Declined -> Unit
|
||||
|
||||
CreateAuthRequestResult.Expired -> {
|
||||
updateContent { content ->
|
||||
content.copy(isResendNotificationLoading = false)
|
||||
}
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = LoginWithDeviceState.DialogState.Error(
|
||||
@@ -279,8 +276,7 @@ class LoginWithDeviceViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendNewAuthRequest(isResend: Boolean) {
|
||||
setIsResendNotificationLoading(isResend)
|
||||
private fun sendNewAuthRequest() {
|
||||
authJob.cancel()
|
||||
authJob = authRepository
|
||||
.createAuthRequestWithUpdates(
|
||||
@@ -291,22 +287,6 @@ class LoginWithDeviceViewModel @Inject constructor(
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun setIsResendNotificationLoading(isResend: Boolean) {
|
||||
updateContent { it.copy(isResendNotificationLoading = isResend) }
|
||||
}
|
||||
|
||||
private inline fun updateContent(
|
||||
crossinline block: (
|
||||
LoginWithDeviceState.ViewState.Content,
|
||||
) -> LoginWithDeviceState.ViewState.Content?,
|
||||
) {
|
||||
val currentViewState = state.viewState
|
||||
val updatedContent = (currentViewState as? LoginWithDeviceState.ViewState.Content)
|
||||
?.let(block)
|
||||
?: return
|
||||
mutableStateFlow.update { it.copy(viewState = updatedContent) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -349,13 +329,10 @@ data class LoginWithDeviceState(
|
||||
* Content state for the [LoginWithDeviceScreen] showing the actual content or items.
|
||||
*
|
||||
* @property fingerprintPhrase The fingerprint phrase to present to the user.
|
||||
* @property isResendNotificationLoading Indicates if the resend loading spinner should be
|
||||
* displayed.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Content(
|
||||
val fingerprintPhrase: String,
|
||||
val isResendNotificationLoading: Boolean,
|
||||
private val loginWithDeviceType: LoginWithDeviceType,
|
||||
) : ViewState() {
|
||||
/**
|
||||
@@ -401,14 +378,19 @@ data class LoginWithDeviceState(
|
||||
/**
|
||||
* The text to display indicating that there are other option for logging in.
|
||||
*/
|
||||
@Suppress("MaxLineLength")
|
||||
val otherOptions: Text
|
||||
get() = when (loginWithDeviceType) {
|
||||
LoginWithDeviceType.OTHER_DEVICE,
|
||||
LoginWithDeviceType.SSO_OTHER_DEVICE,
|
||||
-> BitwardenString.log_in_with_device_must_be_set_up_in_the_settings_of_the_bitwarden_app_need_another_option.asText()
|
||||
-> {
|
||||
BitwardenString
|
||||
.log_in_with_device_must_be_set_up_in_the_settings_of_the_bitwarden_app
|
||||
.asText()
|
||||
}
|
||||
|
||||
LoginWithDeviceType.SSO_ADMIN_APPROVAL -> BitwardenString.trouble_logging_in.asText()
|
||||
LoginWithDeviceType.SSO_ADMIN_APPROVAL -> {
|
||||
BitwardenString.trouble_logging_in.asText()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -319,6 +319,7 @@ private fun TermsAndPrivacyText(
|
||||
style = BitwardenTheme.typography.bodyMedium.copy(
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
|
||||
import com.bitwarden.ui.platform.components.field.BitwardenPasswordField
|
||||
import com.bitwarden.ui.platform.components.field.BitwardenTextField
|
||||
import com.bitwarden.ui.platform.components.model.CardStyle
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost
|
||||
@@ -263,7 +263,7 @@ private fun TwoFactorLoginScreenContent(
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
if (state.shouldShowCodeInput) {
|
||||
BitwardenPasswordField(
|
||||
BitwardenTextField(
|
||||
value = state.codeInput,
|
||||
onValueChange = onCodeInputChange,
|
||||
label = stringResource(id = BitwardenString.verification_code),
|
||||
|
||||
@@ -185,7 +185,7 @@ class TwoFactorLoginViewModel @Inject constructor(
|
||||
// The url should not be empty unless the environment is somehow not supported.
|
||||
authUrl
|
||||
?.let {
|
||||
sendEvent(event = TwoFactorLoginEvent.NavigateToDuo(uri = Uri.parse(it)))
|
||||
sendEvent(event = TwoFactorLoginEvent.NavigateToDuo(uri = it.toUri()))
|
||||
}
|
||||
?: mutableStateFlow.update {
|
||||
it.copy(
|
||||
|
||||
@@ -21,7 +21,6 @@ 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.platform.manager.AppResumeManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
|
||||
import com.x8bit.bitwarden.data.platform.manager.util.toCreateCredentialRequestOrNull
|
||||
@@ -55,7 +54,6 @@ private const val KEY_STATE = "state"
|
||||
class VaultUnlockViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val vaultRepo: VaultRepository,
|
||||
private val biometricsEncryptionManager: BiometricsEncryptionManager,
|
||||
private val specialCircumstanceManager: SpecialCircumstanceManager,
|
||||
private val bitwardenCredentialManager: BitwardenCredentialManager,
|
||||
private val appResumeManager: AppResumeManager,
|
||||
@@ -68,10 +66,6 @@ class VaultUnlockViewModel @Inject constructor(
|
||||
val activeAccount = userState.activeAccount
|
||||
val accountSummaries = userState.toAccountSummaries()
|
||||
val activeAccountSummary = userState.toActiveAccountSummary()
|
||||
val isBiometricsValid = biometricsEncryptionManager.isBiometricIntegrityValid(
|
||||
userId = userState.activeUserId,
|
||||
cipher = biometricsEncryptionManager.getOrCreateCipher(userState.activeUserId),
|
||||
)
|
||||
val vaultUnlockType = activeAccount.vaultUnlockType
|
||||
val hasNoMasterPassword = !activeAccount.hasMasterPassword
|
||||
if (!activeAccount.hasManualUnlockMechanism) {
|
||||
@@ -82,7 +76,6 @@ class VaultUnlockViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
val specialCircumstance = specialCircumstanceManager.specialCircumstance
|
||||
|
||||
val showAccountMenu =
|
||||
savedStateHandle.toVaultUnlockArgs().unlockType == UnlockType.STANDARD &&
|
||||
(specialCircumstance !is SpecialCircumstance.ProviderGetCredentials &&
|
||||
@@ -97,7 +90,7 @@ class VaultUnlockViewModel @Inject constructor(
|
||||
environmentUrl = activeAccount.environment.label,
|
||||
input = "",
|
||||
isBiometricEnabled = activeAccount.isBiometricsEnabled,
|
||||
isBiometricsValid = isBiometricsValid,
|
||||
isBiometricsValid = authRepository.isBiometricIntegrityValid(userState.activeUserId),
|
||||
showAccountMenu = showAccountMenu,
|
||||
showBiometricInvalidatedMessage = false,
|
||||
vaultUnlockType = vaultUnlockType,
|
||||
@@ -233,7 +226,7 @@ class VaultUnlockViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun handleBiometricsUnlockClick() {
|
||||
val cipher = biometricsEncryptionManager.getOrCreateCipher(state.userId)
|
||||
val cipher = authRepository.getOrCreateCipher(state.userId)
|
||||
if (cipher != null) {
|
||||
sendEvent(
|
||||
event = VaultUnlockEvent.PromptForBiometrics(
|
||||
@@ -244,7 +237,7 @@ class VaultUnlockViewModel @Inject constructor(
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isBiometricsValid = false,
|
||||
showBiometricInvalidatedMessage = !biometricsEncryptionManager
|
||||
showBiometricInvalidatedMessage = !authRepository
|
||||
.isAccountBiometricIntegrityValid(state.userId),
|
||||
)
|
||||
}
|
||||
@@ -351,7 +344,7 @@ class VaultUnlockViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
is VaultUnlockResult.BiometricDecodingError -> {
|
||||
biometricsEncryptionManager.clearBiometrics(userId = state.userId)
|
||||
authRepository.clearBiometrics(userId = state.userId)
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isBiometricsValid = false,
|
||||
@@ -433,13 +426,9 @@ class VaultUnlockViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun promptForBiometricsIfAvailable() {
|
||||
val cipher = biometricsEncryptionManager.getOrCreateCipher(state.userId)
|
||||
val cipher = authRepository.getOrCreateCipher(state.userId)
|
||||
if (state.showBiometricLogin && cipher != null && !state.isFromLockFlow) {
|
||||
sendEvent(
|
||||
VaultUnlockEvent.PromptForBiometrics(
|
||||
cipher = cipher,
|
||||
),
|
||||
)
|
||||
sendEvent(VaultUnlockEvent.PromptForBiometrics(cipher = cipher))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ private fun AutofillSelectionDialog(
|
||||
selectionItems = {
|
||||
if (AutofillSelectionOption.AUTOFILL in displayItem.autofillSelectionOptions) {
|
||||
BitwardenBasicDialogRow(
|
||||
text = stringResource(id = BitwardenString.autofill_title),
|
||||
text = stringResource(id = BitwardenString.autofill_verb),
|
||||
onClick = {
|
||||
selectionCallback(
|
||||
displayItem,
|
||||
|
||||
@@ -241,7 +241,7 @@ enum class Settings(
|
||||
testTag = "AccountSecuritySettingsButton",
|
||||
),
|
||||
AUTO_FILL(
|
||||
text = BitwardenString.autofill_title.asText(),
|
||||
text = BitwardenString.autofill_noun.asText(),
|
||||
vectorIconRes = BitwardenDrawable.ic_check_mark,
|
||||
testTag = "AutofillSettingsButton",
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.core.data.manager.BuildInfoManager
|
||||
import com.bitwarden.data.datasource.disk.model.FlightRecorderDataSet
|
||||
import com.bitwarden.data.repository.ServerConfigRepository
|
||||
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
@@ -11,7 +12,6 @@ import com.bitwarden.ui.platform.manager.util.deviceData
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.bitwarden.ui.util.concat
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
|
||||
import com.x8bit.bitwarden.data.platform.manager.LogsManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
|
||||
@@ -2,10 +2,10 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.about.util
|
||||
|
||||
import com.bitwarden.core.data.util.toFormattedDateStyle
|
||||
import com.bitwarden.core.data.util.toFormattedTimeStyle
|
||||
import com.bitwarden.data.datasource.disk.model.FlightRecorderDataSet
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
@@ -18,7 +18,6 @@ import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
|
||||
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
@@ -51,28 +50,20 @@ class AccountSecurityViewModel @Inject constructor(
|
||||
private val vaultRepository: VaultRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val environmentRepository: EnvironmentRepository,
|
||||
private val biometricsEncryptionManager: BiometricsEncryptionManager,
|
||||
private val firstTimeActionManager: FirstTimeActionManager,
|
||||
policyManager: PolicyManager,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<AccountSecurityState, AccountSecurityEvent, AccountSecurityAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: run {
|
||||
val userId = requireNotNull(authRepository.userStateFlow.value).activeUserId
|
||||
val isBiometricsValid = biometricsEncryptionManager.isBiometricIntegrityValid(
|
||||
userId = userId,
|
||||
cipher = biometricsEncryptionManager.getOrCreateCipher(userId),
|
||||
)
|
||||
val userState = requireNotNull(authRepository.userStateFlow.value)
|
||||
val userId = userState.activeUserId
|
||||
AccountSecurityState(
|
||||
dialog = null,
|
||||
fingerprintPhrase = "".asText(), // This will be filled in dynamically
|
||||
isAuthenticatorSyncChecked = settingsRepository.isAuthenticatorSyncEnabled,
|
||||
isUnlockWithBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled &&
|
||||
isBiometricsValid,
|
||||
isUnlockWithPasswordEnabled = authRepository
|
||||
.userStateFlow
|
||||
.value
|
||||
?.activeAccount
|
||||
?.hasMasterPassword != false,
|
||||
authRepository.isBiometricIntegrityValid(userId = userId),
|
||||
isUnlockWithPasswordEnabled = userState.activeAccount.hasMasterPassword,
|
||||
isUnlockWithPinEnabled = settingsRepository.isUnlockWithPinEnabled,
|
||||
shouldShowEnableAuthenticatorSync = isBuildVersionAtLeast(Build.VERSION_CODES.S),
|
||||
userId = userId,
|
||||
@@ -230,7 +221,7 @@ class AccountSecurityViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun handleEnableBiometricsClick() {
|
||||
biometricsEncryptionManager
|
||||
authRepository
|
||||
.createCipherOrNull(userId = state.userId)
|
||||
?.let {
|
||||
sendEvent(
|
||||
@@ -319,7 +310,7 @@ class AccountSecurityViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun handleUnlockWithBiometricToggleDisabled() {
|
||||
biometricsEncryptionManager.clearBiometrics(userId = state.userId)
|
||||
authRepository.clearBiometrics(userId = state.userId)
|
||||
mutableStateFlow.update { it.copy(isUnlockWithBiometricsEnabled = false) }
|
||||
validateVaultTimeoutAction()
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ fun AutoFillScreen(
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = BitwardenString.autofill_title),
|
||||
title = stringResource(id = BitwardenString.autofill_noun),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
|
||||
navigationIconContentDescription = stringResource(id = BitwardenString.back),
|
||||
@@ -196,7 +196,7 @@ private fun AutoFillScreenContent(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(height = 16.dp))
|
||||
BitwardenListHeaderText(
|
||||
label = stringResource(id = BitwardenString.autofill_title),
|
||||
label = stringResource(id = BitwardenString.autofill_noun),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin()
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.core.data.util.toFormattedPattern
|
||||
import com.bitwarden.data.manager.file.FileManager
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
|
||||
@@ -22,7 +23,6 @@ import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
|
||||
import com.x8bit.bitwarden.data.platform.manager.util.hasRestrictItemTypes
|
||||
import com.x8bit.bitwarden.data.vault.manager.FileManager
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
|
||||
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.data.manager.model.FlightRecorderDuration
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
@@ -39,7 +40,6 @@ import com.bitwarden.ui.platform.manager.IntentManager
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.FlightRecorderDuration
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.util.displayText
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.bitwarden.data.manager.model.FlightRecorderDuration
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.FlightRecorderDuration
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.update
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -4,14 +4,14 @@ import android.os.Parcelable
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.data.datasource.disk.model.FlightRecorderDataSet
|
||||
import com.bitwarden.data.manager.file.FileManager
|
||||
import com.bitwarden.data.manager.model.ZipFileResult
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.vault.manager.FileManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.ZipFileResult
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedLogs.util.toViewState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@@ -3,13 +3,13 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recorded
|
||||
import com.bitwarden.core.data.util.toFormattedDateStyle
|
||||
import com.bitwarden.core.data.util.toFormattedPattern
|
||||
import com.bitwarden.core.data.util.toFormattedTimeStyle
|
||||
import com.bitwarden.core.util.fileOf
|
||||
import com.bitwarden.data.datasource.disk.model.FlightRecorderDataSet
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.platform.util.formatBytes
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
|
||||
import com.x8bit.bitwarden.data.platform.util.fileOf
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedLogs.RecordedLogsState
|
||||
import com.x8bit.bitwarden.ui.platform.util.formatBytes
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.util
|
||||
|
||||
import com.bitwarden.data.manager.model.FlightRecorderDuration
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.FlightRecorderDuration
|
||||
|
||||
/**
|
||||
* A helper function to map the [FlightRecorderDuration] to a displayable label.
|
||||
|
||||
@@ -2,14 +2,17 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.vault
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.core.data.manager.model.FlagKey
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.ui.platform.base.BackgroundEvent
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
|
||||
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
@@ -25,6 +28,7 @@ class VaultSettingsViewModel @Inject constructor(
|
||||
snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
|
||||
private val firstTimeActionManager: FirstTimeActionManager,
|
||||
private val featureFlagManager: FeatureFlagManager,
|
||||
private val policyManager: PolicyManager,
|
||||
) : BaseViewModel<VaultSettingsState, VaultSettingsEvent, VaultSettingsAction>(
|
||||
initialState = run {
|
||||
val firstTimeState = firstTimeActionManager.currentOrDefaultUserFirstTimeState
|
||||
@@ -55,7 +59,13 @@ class VaultSettingsViewModel @Inject constructor(
|
||||
|
||||
featureFlagManager
|
||||
.getFeatureFlagFlow(key = FlagKey.CredentialExchangeProtocolImport)
|
||||
.map { VaultSettingsAction.Internal.ImportFeatureUpdated(it) }
|
||||
.combine(
|
||||
policyManager.getActivePoliciesFlow(type = PolicyTypeJson.PERSONAL_OWNERSHIP),
|
||||
) { isEnabled, policies ->
|
||||
VaultSettingsAction.Internal.CredentialExchangeAvailabilityChanged(
|
||||
isEnabled = isEnabled && policies.isEmpty(),
|
||||
)
|
||||
}
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
@@ -80,8 +90,8 @@ class VaultSettingsViewModel @Inject constructor(
|
||||
handleSnackbarDataReceived(action)
|
||||
}
|
||||
|
||||
is VaultSettingsAction.Internal.ImportFeatureUpdated -> {
|
||||
handleImportFeatureUpdated(action)
|
||||
is VaultSettingsAction.Internal.CredentialExchangeAvailabilityChanged -> {
|
||||
handleCredentialExchangeAvailabilityChanged(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,8 +102,8 @@ class VaultSettingsViewModel @Inject constructor(
|
||||
sendEvent(VaultSettingsEvent.ShowSnackbar(action.data))
|
||||
}
|
||||
|
||||
private fun handleImportFeatureUpdated(
|
||||
action: VaultSettingsAction.Internal.ImportFeatureUpdated,
|
||||
private fun handleCredentialExchangeAvailabilityChanged(
|
||||
action: VaultSettingsAction.Internal.CredentialExchangeAvailabilityChanged,
|
||||
) {
|
||||
mutableStateFlow.update { it.copy(showImportItemsChevron = action.isEnabled) }
|
||||
}
|
||||
@@ -128,7 +138,9 @@ class VaultSettingsViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun handleImportItemsClicked() {
|
||||
if (featureFlagManager.getFeatureFlag(FlagKey.CredentialExchangeProtocolImport)) {
|
||||
if (featureFlagManager.getFeatureFlag(FlagKey.CredentialExchangeProtocolImport) &&
|
||||
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP).isEmpty()
|
||||
) {
|
||||
sendEvent(VaultSettingsEvent.NavigateToImportItems)
|
||||
} else {
|
||||
sendEvent(VaultSettingsEvent.NavigateToImportVault)
|
||||
@@ -218,9 +230,9 @@ sealed class VaultSettingsAction {
|
||||
*/
|
||||
sealed class Internal : VaultSettingsAction() {
|
||||
/**
|
||||
* Indicates that the import feature flag has been updated.
|
||||
* Indicates that the CXF import feature availability has changed.
|
||||
*/
|
||||
data class ImportFeatureUpdated(
|
||||
data class CredentialExchangeAvailabilityChanged(
|
||||
val isEnabled: Boolean,
|
||||
) : Internal()
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ class BitwardenBuildInfoManagerImpl : BuildInfoManager {
|
||||
override val applicationId: String
|
||||
get() = BuildConfig.APPLICATION_ID
|
||||
|
||||
override val applicationName: String get() = "Password Manager"
|
||||
|
||||
override val isFdroid: Boolean
|
||||
get() = BuildConfig.FLAVOR == "fdroid"
|
||||
|
||||
@@ -39,4 +41,6 @@ class BitwardenBuildInfoManagerImpl : BuildInfoManager {
|
||||
"release" -> "prod"
|
||||
else -> BuildConfig.BUILD_TYPE
|
||||
}
|
||||
|
||||
override val buildAndFlavor: String get() = "${BuildConfig.BUILD_TYPE}/${BuildConfig.FLAVOR}"
|
||||
}
|
||||
|
||||
@@ -24,4 +24,5 @@ enum class SnackbarRelay {
|
||||
LOGINS_IMPORTED,
|
||||
SEND_DELETED,
|
||||
SEND_UPDATED,
|
||||
LEFT_ORGANIZATION,
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.bitwarden.core.data.repository.model.DataState
|
||||
import com.bitwarden.core.data.repository.util.combineDataStates
|
||||
import com.bitwarden.core.data.repository.util.mapNullable
|
||||
import com.bitwarden.core.util.persistentListOfNotNull
|
||||
import com.bitwarden.data.manager.file.FileManager
|
||||
import com.bitwarden.data.repository.util.baseIconUrl
|
||||
import com.bitwarden.ui.platform.base.BackgroundEvent
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
@@ -29,7 +30,6 @@ import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.vault.manager.FileManager
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
|
||||
|
||||
@@ -257,7 +257,7 @@ private fun LoginUriView.toUriData() =
|
||||
isLaunchable = !uri.isNullOrBlank(),
|
||||
)
|
||||
|
||||
private fun Fido2Credential.getCreationDateText(clock: Clock): Text? =
|
||||
private fun Fido2Credential.getCreationDateText(clock: Clock): Text =
|
||||
BitwardenString.created_x.asText(
|
||||
this.creationDate.toFormattedDateTimeStyle(
|
||||
dateStyle = FormatStyle.MEDIUM,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
@file:OmitFromCoverage
|
||||
|
||||
package com.x8bit.bitwarden.ui.vault.feature.leaveorganization
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.toRoute
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The type-safe route for the leave organization screen.
|
||||
*
|
||||
* @property organizationId The ID of the organization to leave.
|
||||
* @property organizationName The name of the organization to leave.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
@Serializable
|
||||
data class LeaveOrganizationRoute(
|
||||
val organizationId: String,
|
||||
val organizationName: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Class to retrieve leave organization arguments from the [SavedStateHandle].
|
||||
*
|
||||
* @property organizationId The ID of the organization to leave.
|
||||
* @property organizationName The name of the organization to leave.
|
||||
*/
|
||||
data class LeaveOrganizationArgs(
|
||||
val organizationId: String,
|
||||
val organizationName: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Constructs a [LeaveOrganizationArgs] from the [SavedStateHandle] and internal route data.
|
||||
*/
|
||||
fun SavedStateHandle.toLeaveOrganizationArgs(): LeaveOrganizationArgs {
|
||||
val route = this.toRoute<LeaveOrganizationRoute>()
|
||||
return LeaveOrganizationArgs(
|
||||
organizationId = route.organizationId,
|
||||
organizationName = route.organizationName,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the leave organization screen to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.leaveOrganizationDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToVault: () -> Unit,
|
||||
) {
|
||||
composableWithPushTransitions<LeaveOrganizationRoute> {
|
||||
LeaveOrganizationScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateToVault = onNavigateToVault,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the leave organization screen.
|
||||
*
|
||||
* @param organizationId The ID of the organization to leave.
|
||||
* @param organizationName The name of the organization to leave.
|
||||
*/
|
||||
fun NavController.navigateToLeaveOrganization(
|
||||
organizationId: String,
|
||||
organizationName: String,
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
this.navigate(
|
||||
route = LeaveOrganizationRoute(
|
||||
organizationId = organizationId,
|
||||
organizationName = organizationName,
|
||||
),
|
||||
navOptions = navOptions,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.leaveorganization
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenFilledErrorButton
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.composition.LocalIntentManager
|
||||
import com.bitwarden.ui.platform.manager.IntentManager
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import com.x8bit.bitwarden.ui.vault.feature.leaveorganization.handlers.rememberLeaveOrganizationHandler
|
||||
|
||||
/**
|
||||
* Top-level composable for the Leave Organization screen.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LeaveOrganizationScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToVault: () -> Unit,
|
||||
viewModel: LeaveOrganizationViewModel = hiltViewModel(),
|
||||
intentManager: IntentManager = LocalIntentManager.current,
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
val handlers = rememberLeaveOrganizationHandler(viewModel)
|
||||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
LeaveOrganizationEvent.NavigateBack -> onNavigateBack()
|
||||
LeaveOrganizationEvent.NavigateToVault -> onNavigateToVault()
|
||||
is LeaveOrganizationEvent.LaunchUri -> {
|
||||
intentManager.launchUri(event.uri.toUri())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LeaveOrganizationDialogs(
|
||||
dialogState = state.dialogState,
|
||||
onDismissRequest = handlers.onDismissDialog,
|
||||
)
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = BitwardenString.leave_organization),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
|
||||
navigationIconContentDescription = stringResource(id = BitwardenString.back),
|
||||
onNavigationIconClick = handlers.onBackClick,
|
||||
)
|
||||
},
|
||||
) {
|
||||
LeaveOrganizationContent(
|
||||
state = state,
|
||||
onLeaveClick = handlers.onLeaveClick,
|
||||
onHelpLinkClick = handlers.onHelpClick,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeaveOrganizationDialogs(
|
||||
dialogState: LeaveOrganizationState.DialogState?,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
when (dialogState) {
|
||||
LeaveOrganizationState.DialogState.Loading -> {
|
||||
BitwardenLoadingDialog(
|
||||
text = stringResource(id = BitwardenString.loading),
|
||||
)
|
||||
}
|
||||
|
||||
is LeaveOrganizationState.DialogState.Error -> {
|
||||
BitwardenBasicDialog(
|
||||
title = stringResource(id = BitwardenString.an_error_has_occurred),
|
||||
message = dialogState.message(),
|
||||
throwable = dialogState.error,
|
||||
onDismissRequest = onDismissRequest,
|
||||
)
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun LeaveOrganizationContent(
|
||||
state: LeaveOrganizationState,
|
||||
onLeaveClick: () -> Unit,
|
||||
onHelpLinkClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Image(
|
||||
painter = rememberVectorPainter(id = BitwardenDrawable.ill_leave_organization),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillHeight,
|
||||
modifier = Modifier
|
||||
.standardHorizontalMargin()
|
||||
.size(100.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = BitwardenString.are_you_sure_you_want_to_leave_organization,
|
||||
state.organizationName,
|
||||
),
|
||||
style = BitwardenTheme.typography.titleMedium,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(id = BitwardenString.leave_organization_warning),
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
BitwardenFilledErrorButton(
|
||||
label = stringResource(
|
||||
id = BitwardenString.leave_organization_button,
|
||||
state.organizationName,
|
||||
),
|
||||
onClick = onLeaveClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
BitwardenTextButton(
|
||||
label = stringResource(id = BitwardenString.how_to_manage_my_vault),
|
||||
onClick = onHelpLinkClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun LeaveOrganizationScreen_preview() {
|
||||
BitwardenTheme {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = "Leave organization",
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
|
||||
navigationIconContentDescription = "Back",
|
||||
onNavigationIconClick = {},
|
||||
)
|
||||
},
|
||||
) {
|
||||
LeaveOrganizationContent(
|
||||
state = LeaveOrganizationState(
|
||||
organizationId = "",
|
||||
organizationName = "Test Organization",
|
||||
dialogState = null,
|
||||
),
|
||||
onLeaveClick = {},
|
||||
onHelpLinkClick = {},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.leaveorganization
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
|
||||
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
|
||||
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
|
||||
/**
|
||||
* ViewModel for the Leave Organization screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class LeaveOrganizationViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<LeaveOrganizationState, LeaveOrganizationEvent, LeaveOrganizationAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: run {
|
||||
val args = savedStateHandle.toLeaveOrganizationArgs()
|
||||
LeaveOrganizationState(
|
||||
organizationId = args.organizationId,
|
||||
organizationName = args.organizationName,
|
||||
dialogState = null,
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
||||
init {
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: LeaveOrganizationAction) {
|
||||
when (action) {
|
||||
LeaveOrganizationAction.BackClick -> handleBackClick()
|
||||
LeaveOrganizationAction.LeaveOrganizationClick -> handleLeaveOrganizationClick()
|
||||
LeaveOrganizationAction.HelpLinkClick -> handleHelpLinkClick()
|
||||
LeaveOrganizationAction.DismissDialog -> handleDismissDialog()
|
||||
is LeaveOrganizationAction.Internal.LeaveOrganizationResultReceived -> {
|
||||
handleLeaveOrganizationResultReceived(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBackClick() {
|
||||
sendEvent(LeaveOrganizationEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleLeaveOrganizationClick() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = LeaveOrganizationState.DialogState.Loading)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val result = authRepository.leaveOrganization(state.organizationId)
|
||||
sendAction(
|
||||
LeaveOrganizationAction.Internal.LeaveOrganizationResultReceived(result),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleHelpLinkClick() {
|
||||
sendEvent(
|
||||
LeaveOrganizationEvent.LaunchUri(
|
||||
uri = "https://bitwarden.com/help/transfer-ownership/",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleDismissDialog() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleLeaveOrganizationResultReceived(
|
||||
action: LeaveOrganizationAction.Internal.LeaveOrganizationResultReceived,
|
||||
) {
|
||||
when (val result = action.result) {
|
||||
is LeaveOrganizationResult.Success -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = null)
|
||||
}
|
||||
snackbarRelayManager.sendSnackbarData(
|
||||
relay = SnackbarRelay.LEFT_ORGANIZATION,
|
||||
data = BitwardenSnackbarData(
|
||||
message = BitwardenString.you_left_the_organization.asText(),
|
||||
),
|
||||
)
|
||||
sendEvent(LeaveOrganizationEvent.NavigateToVault)
|
||||
}
|
||||
|
||||
is LeaveOrganizationResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = LeaveOrganizationState.DialogState.Error(
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
error = result.error,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the Leave Organization screen.
|
||||
*/
|
||||
@Parcelize
|
||||
data class LeaveOrganizationState(
|
||||
val organizationId: String,
|
||||
val organizationName: String,
|
||||
val dialogState: DialogState?,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* Dialog states for transient UI.
|
||||
*/
|
||||
sealed class DialogState : Parcelable {
|
||||
/**
|
||||
* Loading dialog during leave operation.
|
||||
*/
|
||||
@Parcelize
|
||||
data object Loading : DialogState()
|
||||
|
||||
/**
|
||||
* Error dialog when leave operation fails.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Error(
|
||||
val message: Text,
|
||||
val error: Throwable? = null,
|
||||
) : DialogState()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Events for the Leave Organization screen.
|
||||
*/
|
||||
sealed class LeaveOrganizationEvent {
|
||||
/**
|
||||
* Navigate back to previous screen.
|
||||
*/
|
||||
data object NavigateBack : LeaveOrganizationEvent()
|
||||
|
||||
/**
|
||||
* Navigate to the Vault screen.
|
||||
*/
|
||||
data object NavigateToVault : LeaveOrganizationEvent()
|
||||
|
||||
/**
|
||||
* Launch external URI.
|
||||
*/
|
||||
data class LaunchUri(val uri: String) : LeaveOrganizationEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions for the Leave Organization screen.
|
||||
*/
|
||||
sealed class LeaveOrganizationAction {
|
||||
/**
|
||||
* User clicked the back button.
|
||||
*/
|
||||
data object BackClick : LeaveOrganizationAction()
|
||||
|
||||
/**
|
||||
* User clicked the leave organization button.
|
||||
*/
|
||||
data object LeaveOrganizationClick : LeaveOrganizationAction()
|
||||
|
||||
/**
|
||||
* User clicked the help link.
|
||||
*/
|
||||
data object HelpLinkClick : LeaveOrganizationAction()
|
||||
|
||||
/**
|
||||
* User dismissed a dialog.
|
||||
*/
|
||||
data object DismissDialog : LeaveOrganizationAction()
|
||||
|
||||
/**
|
||||
* Internal actions for ViewModel processing.
|
||||
*/
|
||||
sealed class Internal : LeaveOrganizationAction() {
|
||||
/**
|
||||
* Leave organization result received from repository.
|
||||
*/
|
||||
data class LeaveOrganizationResultReceived(
|
||||
val result: LeaveOrganizationResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.leaveorganization.handlers
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.x8bit.bitwarden.ui.vault.feature.leaveorganization.LeaveOrganizationAction
|
||||
import com.x8bit.bitwarden.ui.vault.feature.leaveorganization.LeaveOrganizationViewModel
|
||||
|
||||
/**
|
||||
* A class to handle user interactions for the Leave Organization screen.
|
||||
*/
|
||||
data class LeaveOrganizationHandler(
|
||||
val onBackClick: () -> Unit,
|
||||
val onLeaveClick: () -> Unit,
|
||||
val onHelpClick: () -> Unit,
|
||||
val onDismissDialog: () -> Unit,
|
||||
) {
|
||||
@Suppress("UndocumentedPublicClass")
|
||||
companion object {
|
||||
/**
|
||||
* Creates an instance of [LeaveOrganizationHandler] using the provided
|
||||
* [LeaveOrganizationViewModel].
|
||||
*/
|
||||
fun create(viewModel: LeaveOrganizationViewModel): LeaveOrganizationHandler =
|
||||
LeaveOrganizationHandler(
|
||||
onBackClick = {
|
||||
viewModel.trySendAction(LeaveOrganizationAction.BackClick)
|
||||
},
|
||||
onLeaveClick = {
|
||||
viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick)
|
||||
},
|
||||
onHelpClick = {
|
||||
viewModel.trySendAction(LeaveOrganizationAction.HelpLinkClick)
|
||||
},
|
||||
onDismissDialog = {
|
||||
viewModel.trySendAction(LeaveOrganizationAction.DismissDialog)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create and remember a [LeaveOrganizationHandler] instance.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberLeaveOrganizationHandler(
|
||||
viewModel: LeaveOrganizationViewModel,
|
||||
): LeaveOrganizationHandler = remember(viewModel) {
|
||||
LeaveOrganizationHandler.create(viewModel)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
@file:OmitFromCoverage
|
||||
|
||||
package com.x8bit.bitwarden.ui.vault.feature.migratetomyitems
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.toRoute
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The type-safe route for the migrate to my items screen.
|
||||
*
|
||||
* @property organizationId The ID of the organization requiring migration.
|
||||
* @property organizationName The name of the organization requiring migration.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
@Serializable
|
||||
data class MigrateToMyItemsRoute(
|
||||
val organizationId: String,
|
||||
val organizationName: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Class to retrieve migrate to my items arguments from the [SavedStateHandle].
|
||||
*
|
||||
* @property organizationId The ID of the organization requiring migration.
|
||||
* @property organizationName The name of the organization requiring migration.
|
||||
*/
|
||||
data class MigrateToMyItemsArgs(
|
||||
val organizationId: String,
|
||||
val organizationName: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Constructs a [MigrateToMyItemsArgs] from the [SavedStateHandle] and internal route data.
|
||||
*/
|
||||
fun SavedStateHandle.toMigrateToMyItemsArgs(): MigrateToMyItemsArgs {
|
||||
val route = this.toRoute<MigrateToMyItemsRoute>()
|
||||
return MigrateToMyItemsArgs(
|
||||
organizationId = route.organizationId,
|
||||
organizationName = route.organizationName,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the migrate to my items screen.
|
||||
*
|
||||
* @param organizationId The ID of the organization requiring migration.
|
||||
* @param organizationName The name of the organization requiring migration.
|
||||
*/
|
||||
fun NavController.navigateToMigrateToMyItems(
|
||||
organizationId: String,
|
||||
organizationName: String,
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
this.navigate(
|
||||
route = MigrateToMyItemsRoute(
|
||||
organizationId = organizationId,
|
||||
organizationName = organizationName,
|
||||
),
|
||||
navOptions = navOptions,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the migrate to my items screen to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.migrateToMyItemsDestination(
|
||||
onNavigateToVault: () -> Unit,
|
||||
onNavigateToLeaveOrganization: () -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions<MigrateToMyItemsRoute> {
|
||||
MigrateToMyItemsScreen(
|
||||
onNavigateToVault = onNavigateToVault,
|
||||
onNavigateToLeaveOrganization = onNavigateToLeaveOrganization,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.migratetomyitems
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.composition.LocalIntentManager
|
||||
import com.bitwarden.ui.platform.manager.IntentManager
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.handler.rememberMigrateToMyItemsHandler
|
||||
|
||||
/**
|
||||
* Top level screen component for the MigrateToMyItems screen.
|
||||
*/
|
||||
@Composable
|
||||
fun MigrateToMyItemsScreen(
|
||||
onNavigateToVault: () -> Unit,
|
||||
onNavigateToLeaveOrganization: () -> Unit,
|
||||
viewModel: MigrateToMyItemsViewModel = hiltViewModel(),
|
||||
intentManager: IntentManager = LocalIntentManager.current,
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
val handlers = rememberMigrateToMyItemsHandler(viewModel)
|
||||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
MigrateToMyItemsEvent.NavigateToVault -> onNavigateToVault()
|
||||
MigrateToMyItemsEvent.NavigateToLeaveOrganization -> onNavigateToLeaveOrganization()
|
||||
is MigrateToMyItemsEvent.LaunchUri -> intentManager.launchUri(event.uri.toUri())
|
||||
}
|
||||
}
|
||||
|
||||
MigrateToMyItemsDialogs(
|
||||
dialog = state.dialog,
|
||||
onDismissRequest = handlers.onDismissDialog,
|
||||
)
|
||||
|
||||
BitwardenScaffold {
|
||||
MigrateToMyItemsContent(
|
||||
state = state,
|
||||
onAcceptClick = handlers.onAcceptClick,
|
||||
onDeclineClick = handlers.onDeclineClick,
|
||||
onHelpClick = handlers.onHelpClick,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MigrateToMyItemsDialogs(
|
||||
dialog: MigrateToMyItemsState.DialogState?,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
when (dialog) {
|
||||
is MigrateToMyItemsState.DialogState.Error -> {
|
||||
BitwardenBasicDialog(
|
||||
title = dialog.title(),
|
||||
message = dialog.message(),
|
||||
onDismissRequest = onDismissRequest,
|
||||
)
|
||||
}
|
||||
|
||||
is MigrateToMyItemsState.DialogState.Loading -> {
|
||||
BitwardenLoadingDialog(text = dialog.message())
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MigrateToMyItemsContent(
|
||||
state: MigrateToMyItemsState,
|
||||
onAcceptClick: () -> Unit,
|
||||
onDeclineClick: () -> Unit,
|
||||
onHelpClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Image(
|
||||
painter = rememberVectorPainter(id = BitwardenDrawable.ill_migrate_to_my_items),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillHeight,
|
||||
modifier = Modifier
|
||||
.standardHorizontalMargin()
|
||||
.size(100.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
MigrateToMyItemsTextContent(organizationName = state.organizationName)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
MigrateToMyItemsActions(
|
||||
onContinueClick = onAcceptClick,
|
||||
onDeclineClick = onDeclineClick,
|
||||
onHelpClick = onHelpClick,
|
||||
)
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MigrateToMyItemsTextContent(
|
||||
organizationName: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = BitwardenString.transfer_items_to_org,
|
||||
organizationName,
|
||||
),
|
||||
style = BitwardenTheme.typography.titleMedium,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = BitwardenString.transfer_items_description,
|
||||
organizationName,
|
||||
),
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MigrateToMyItemsActions(
|
||||
onContinueClick: () -> Unit,
|
||||
onDeclineClick: () -> Unit,
|
||||
onHelpClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
BitwardenFilledButton(
|
||||
label = stringResource(id = BitwardenString.accept),
|
||||
onClick = onContinueClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
BitwardenOutlinedButton(
|
||||
label = stringResource(id = BitwardenString.decline_and_leave),
|
||||
onClick = onDeclineClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
BitwardenTextButton(
|
||||
label = stringResource(id = BitwardenString.why_am_i_seeing_this),
|
||||
onClick = onHelpClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun MigrateToMyItemsScreen_preview() {
|
||||
BitwardenTheme {
|
||||
BitwardenScaffold {
|
||||
MigrateToMyItemsContent(
|
||||
state = MigrateToMyItemsState(
|
||||
organizationId = "test-org-id",
|
||||
organizationName = "Bitwarden",
|
||||
dialog = null,
|
||||
),
|
||||
onAcceptClick = {},
|
||||
onDeclineClick = {},
|
||||
onHelpClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.migratetomyitems
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asText
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
|
||||
/**
|
||||
* View model for the [MigrateToMyItemsScreen].
|
||||
*/
|
||||
@HiltViewModel
|
||||
class MigrateToMyItemsViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<MigrateToMyItemsState, MigrateToMyItemsEvent, MigrateToMyItemsAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: run {
|
||||
val args = savedStateHandle.toMigrateToMyItemsArgs()
|
||||
MigrateToMyItemsState(
|
||||
organizationId = args.organizationId,
|
||||
organizationName = args.organizationName,
|
||||
dialog = null,
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
||||
init {
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: MigrateToMyItemsAction) {
|
||||
when (action) {
|
||||
MigrateToMyItemsAction.AcceptClicked -> handleAcceptClicked()
|
||||
MigrateToMyItemsAction.DeclineAndLeaveClicked -> handleDeclineAndLeaveClicked()
|
||||
MigrateToMyItemsAction.HelpLinkClicked -> handleHelpLinkClicked()
|
||||
MigrateToMyItemsAction.DismissDialogClicked -> handleDismissDialogClicked()
|
||||
is MigrateToMyItemsAction.Internal -> handleInternalAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAcceptClicked() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = MigrateToMyItemsState.DialogState.Loading(
|
||||
message = BitwardenString.migrating_items_to_x.asText(
|
||||
it.organizationName,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
// TODO: Replace `delay` with actual migration using `state.organizationId` (PM-28444).
|
||||
delay(timeMillis = 100L)
|
||||
trySendAction(
|
||||
MigrateToMyItemsAction.Internal.MigrateToMyItemsResultReceived(
|
||||
success = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDeclineAndLeaveClicked() {
|
||||
sendEvent(MigrateToMyItemsEvent.NavigateToLeaveOrganization)
|
||||
}
|
||||
|
||||
private fun handleHelpLinkClicked() {
|
||||
sendEvent(
|
||||
MigrateToMyItemsEvent.LaunchUri(
|
||||
uri = "https://bitwarden.com/help/transfer-ownership/",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleDismissDialogClicked() {
|
||||
clearDialog()
|
||||
}
|
||||
|
||||
private fun handleInternalAction(action: MigrateToMyItemsAction.Internal) {
|
||||
when (action) {
|
||||
is MigrateToMyItemsAction.Internal.MigrateToMyItemsResultReceived -> {
|
||||
handleMigrateToMyItemsResultReceived(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMigrateToMyItemsResultReceived(
|
||||
action: MigrateToMyItemsAction.Internal.MigrateToMyItemsResultReceived,
|
||||
) {
|
||||
if (action.success) {
|
||||
clearDialog()
|
||||
sendEvent(MigrateToMyItemsEvent.NavigateToVault)
|
||||
} else {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = MigrateToMyItemsState.DialogState.Error(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.failed_to_migrate_items_to_x.asText(
|
||||
it.organizationName,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearDialog() {
|
||||
mutableStateFlow.update { it.copy(dialog = null) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models the state for the [MigrateToMyItemsScreen].
|
||||
*/
|
||||
@Parcelize
|
||||
data class MigrateToMyItemsState(
|
||||
val organizationId: String,
|
||||
val organizationName: String,
|
||||
val dialog: DialogState?,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* Models the dialog state for the [MigrateToMyItemsScreen].
|
||||
*/
|
||||
sealed class DialogState : Parcelable {
|
||||
|
||||
/**
|
||||
* Displays a loading dialog.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Loading(val message: Text) : DialogState()
|
||||
|
||||
/**
|
||||
* Displays an error dialog.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Error(
|
||||
val title: Text,
|
||||
val message: Text,
|
||||
) : DialogState()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models the events that can be sent from the [MigrateToMyItemsViewModel].
|
||||
*/
|
||||
sealed class MigrateToMyItemsEvent {
|
||||
/**
|
||||
* Navigate to the vault screen after accepting migration.
|
||||
*/
|
||||
data object NavigateToVault : MigrateToMyItemsEvent()
|
||||
|
||||
/**
|
||||
* Navigate to the leave organization flow after declining.
|
||||
*/
|
||||
data object NavigateToLeaveOrganization : MigrateToMyItemsEvent()
|
||||
|
||||
/**
|
||||
* Launch a URI in the browser or appropriate handler.
|
||||
*/
|
||||
data class LaunchUri(val uri: String) : MigrateToMyItemsEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Models the actions that can be handled by the [MigrateToMyItemsViewModel].
|
||||
*/
|
||||
sealed class MigrateToMyItemsAction {
|
||||
/**
|
||||
* User clicked the Accept button.
|
||||
*/
|
||||
data object AcceptClicked : MigrateToMyItemsAction()
|
||||
|
||||
/**
|
||||
* User clicked the Decline and Leave button.
|
||||
*/
|
||||
data object DeclineAndLeaveClicked : MigrateToMyItemsAction()
|
||||
|
||||
/**
|
||||
* User clicked the "Why am I seeing this?" help link.
|
||||
*/
|
||||
data object HelpLinkClicked : MigrateToMyItemsAction()
|
||||
|
||||
/**
|
||||
* User dismissed the dialog.
|
||||
*/
|
||||
data object DismissDialogClicked : MigrateToMyItemsAction()
|
||||
|
||||
/**
|
||||
* Models internal actions that the [MigrateToMyItemsViewModel] itself may send.
|
||||
*/
|
||||
sealed class Internal : MigrateToMyItemsAction() {
|
||||
|
||||
/**
|
||||
* The result of the migration has been received.
|
||||
*/
|
||||
data class MigrateToMyItemsResultReceived(
|
||||
// TODO: Replace `success` with actual migration result (PM-28444).
|
||||
val success: Boolean,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user