mirror of
https://github.com/bitwarden/android.git
synced 2026-05-12 14:51:15 -05:00
Compare commits
133 Commits
release/20
...
release/20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ee3e2e249 | ||
|
|
58f4983790 | ||
|
|
f05cf773fb | ||
|
|
2e311b6c4a | ||
|
|
ee5ed77bc1 | ||
|
|
04a3cd227e | ||
|
|
ec28dde6d2 | ||
|
|
319872ccf9 | ||
|
|
9f1fad8be0 | ||
|
|
0395d489c2 | ||
|
|
2acf429f67 | ||
|
|
721fbbb82c | ||
|
|
6d198bd8c9 | ||
|
|
8658f1d42c | ||
|
|
acc3e24d65 | ||
|
|
40c8346bf7 | ||
|
|
a7badf8b0b | ||
|
|
c52910e74a | ||
|
|
afc1ff4d7a | ||
|
|
8cb4fab1de | ||
|
|
f79113aa7f | ||
|
|
7d814df04e | ||
|
|
49b208f013 | ||
|
|
8d33e6660a | ||
|
|
27a0f5172c | ||
|
|
3e470ebc25 | ||
|
|
eb18ca04a0 | ||
|
|
759e0563a9 | ||
|
|
757f444493 | ||
|
|
98ba1690bf | ||
|
|
44274a888e | ||
|
|
77cc0d5fba | ||
|
|
026393384b | ||
|
|
7daeaca63e | ||
|
|
353e7e9a4e | ||
|
|
2d824f96f5 | ||
|
|
a9b1623f8b | ||
|
|
6d72d3a1c9 | ||
|
|
f6edc19595 | ||
|
|
45125a94c2 | ||
|
|
66900f71df | ||
|
|
d12c546c9a | ||
|
|
be365eec1c | ||
|
|
d86959b375 | ||
|
|
282cce8ce0 | ||
|
|
e8eaf4e68c | ||
|
|
41dfc2b6e8 | ||
|
|
7bfd4b5a6c | ||
|
|
557b667dab | ||
|
|
eff4ce7abb | ||
|
|
577e3c04e3 | ||
|
|
203313eb1d | ||
|
|
5d308aa95f | ||
|
|
c4a94cf5d1 | ||
|
|
5245a7a0c7 | ||
|
|
9432df6ff4 | ||
|
|
461e1e1ff9 | ||
|
|
a8ef32ae76 | ||
|
|
769bfc83af | ||
|
|
29d84d69f5 | ||
|
|
05d003edb2 | ||
|
|
03562a8605 | ||
|
|
e6c46169fb | ||
|
|
7d4d7a25b5 | ||
|
|
1cb37b8458 | ||
|
|
3c7b70f325 | ||
|
|
9a8c504c8b | ||
|
|
b07a92f7d6 | ||
|
|
674cde9869 | ||
|
|
28c9637655 | ||
|
|
2d228b8496 | ||
|
|
3bc538c1f8 | ||
|
|
99717ab5d5 | ||
|
|
d98e459129 | ||
|
|
ebed1bd3cd | ||
|
|
f4e23e85d2 | ||
|
|
474acc05a6 | ||
|
|
87faba6824 | ||
|
|
89fb9c92d3 | ||
|
|
77a58f344d | ||
|
|
dda32075d0 | ||
|
|
038931312d | ||
|
|
7cd0e2c176 | ||
|
|
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 |
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: reviewing-changes
|
||||
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.
|
||||
description: Guides Android code reviews with type-specific checklists and MVVM/Compose pattern validation. Use when reviewing Android PRs, pull requests, diffs, or local changes involving Kotlin, ViewModel, Composable, Repository, or Gradle files. Triggered by "review PR", "review changes", "check this code", "Android review", or code review requests mentioning bitwarden/android. Loads specialized checklists for feature additions, bug fixes, UI refinements, refactoring, dependency updates, and infrastructure changes.
|
||||
---
|
||||
|
||||
# Reviewing Changes - Android Additions
|
||||
|
||||
@@ -11,6 +11,7 @@ Quick reference for Bitwarden Android architectural patterns during code reviews
|
||||
- [Hilt Dependency Injection](#hilt-dependency-injection)
|
||||
- [ViewModels](#viewmodels)
|
||||
- [Repositories and Managers](#repositories-and-managers)
|
||||
- [Clock/Time Handling](#clocktime-handling)
|
||||
- [Module Organization](#module-organization)
|
||||
- [Error Handling](#error-handling)
|
||||
- [Use Result Types, Not Exceptions](#use-result-types-not-exceptions)
|
||||
@@ -210,6 +211,43 @@ abstract class DataModule {
|
||||
|
||||
---
|
||||
|
||||
### Clock/Time Handling
|
||||
|
||||
Time-dependent code must use injected `Clock` rather than direct `Instant.now()` or `DateTime.now()` calls. This follows the same DI principle as other dependencies.
|
||||
|
||||
**✅ GOOD - Injected Clock**:
|
||||
```kotlin
|
||||
// ViewModel with Clock injection
|
||||
class MyViewModel @Inject constructor(
|
||||
private val clock: Clock,
|
||||
) {
|
||||
fun save() {
|
||||
val timestamp = clock.instant()
|
||||
}
|
||||
}
|
||||
|
||||
// Extension function with Clock parameter
|
||||
fun State.getTimestamp(clock: Clock): Instant =
|
||||
existingTime ?: clock.instant()
|
||||
```
|
||||
|
||||
**❌ BAD - Static/direct calls**:
|
||||
```kotlin
|
||||
// Hidden dependency, non-testable
|
||||
val timestamp = Instant.now()
|
||||
val dateTime = DateTime.now()
|
||||
```
|
||||
|
||||
**Key Rules**:
|
||||
- Inject `Clock` via Hilt constructor (like other dependencies)
|
||||
- Pass `Clock` as parameter to extension functions
|
||||
- `Clock` is provided via `CoreModule` as singleton
|
||||
- Enables deterministic testing with `Clock.fixed(...)`
|
||||
|
||||
Reference: `docs/STYLE_AND_BEST_PRACTICES.md#best-practices--time-and-clock-handling`
|
||||
|
||||
---
|
||||
|
||||
## Module Organization
|
||||
|
||||
```
|
||||
@@ -299,6 +337,7 @@ Reference: `docs/ARCHITECTURE.md#error-handling`
|
||||
- [ ] Business logic in Repository, not ViewModel?
|
||||
- [ ] Using Hilt DI (@HiltViewModel, @Inject constructor)?
|
||||
- [ ] Injecting interfaces, not implementations?
|
||||
- [ ] Time-dependent code uses injected `Clock` (not `Instant.now()`)?
|
||||
- [ ] Correct module placement?
|
||||
|
||||
### Error Handling
|
||||
|
||||
@@ -12,7 +12,7 @@ runs:
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -22,7 +22,7 @@ runs:
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
@@ -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
|
||||
|
||||
16
.github/label-pr.json
vendored
16
.github/label-pr.json
vendored
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"catch_all_label": "t:misc",
|
||||
"title_patterns": {
|
||||
"t:new-feature": ["feat", "feature"],
|
||||
"t:enhancement": ["enhancement", "enh", "impr"],
|
||||
"t:feature-app": ["feat", "feature"],
|
||||
"t:feature-tool": ["tool"],
|
||||
"t:bug": ["fix", "bug", "bugfix"],
|
||||
"t:tech-debt": ["refactor", "chore", "cleanup", "revert", "debt", "test", "perf"],
|
||||
"t:docs": ["docs"],
|
||||
@@ -23,12 +22,21 @@
|
||||
],
|
||||
"app:password-manager": [
|
||||
"app/",
|
||||
"cxf/"
|
||||
"cxf/",
|
||||
"testharness/"
|
||||
],
|
||||
"app:authenticator": [
|
||||
"authenticator/"
|
||||
],
|
||||
"t:feature-tool": [
|
||||
"testharness/"
|
||||
],
|
||||
"t:feature-app": [
|
||||
"app/src/main/assets/fido2_privileged_community.json",
|
||||
"app/src/main/assets/fido2_privileged_google.json"
|
||||
],
|
||||
"t:ci": [
|
||||
".checkmarx/",
|
||||
".github/",
|
||||
"scripts/",
|
||||
"fastlane/",
|
||||
|
||||
33
.github/release.yml
vendored
Normal file
33
.github/release.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- ignore-for-release
|
||||
categories:
|
||||
- title: '✨ Community Highlight'
|
||||
labels:
|
||||
- community-pr
|
||||
- title: '🚀 New Features & Enhancements'
|
||||
labels:
|
||||
- t:feature-app
|
||||
- t:new-feature
|
||||
- t:enhancement
|
||||
- title: ':shipit: Tools'
|
||||
labels:
|
||||
- t:feature-tool
|
||||
- title: '❗ Breaking Changes'
|
||||
labels:
|
||||
- t:breaking-change
|
||||
- title: '🐛 Bug fixes'
|
||||
labels:
|
||||
- t:bug
|
||||
- title: '⚙️ Maintenance'
|
||||
labels:
|
||||
- t:tech-debt
|
||||
- t:ci
|
||||
- t:docs
|
||||
- t:misc
|
||||
- '*'
|
||||
- title: '📦 Dependency Updates'
|
||||
labels:
|
||||
- dependencies
|
||||
- t:deps
|
||||
@@ -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())
|
||||
|
||||
61
.github/scripts/label-pr.py
vendored
61
.github/scripts/label-pr.py
vendored
@@ -4,21 +4,22 @@
|
||||
Label pull requests based on changed file paths and PR title patterns (conventional commit format).
|
||||
|
||||
Usage:
|
||||
python label-pr.py <pr-number> [-a|--add|-r|--replace] [-d|--dry-run] [-c|--config CONFIG]
|
||||
python label-pr.py <pr-number> <pr-labels> [-a|--add|-r|--replace] [-d|--dry-run] [-c|--config CONFIG]
|
||||
|
||||
Arguments:
|
||||
pr-number: The pull request number
|
||||
pr-labels: Current PR labels as JSON array string
|
||||
-a, --add: Add labels without removing existing ones (default)
|
||||
-r, --replace: Replace all existing labels
|
||||
-d, --dry-run: Run without actually applying labels
|
||||
-c, --config: Path to JSON config file (default: .github/label-pr.json)
|
||||
|
||||
Examples:
|
||||
python label-pr.py 1234
|
||||
python label-pr.py 1234 -a
|
||||
python label-pr.py 1234 --replace
|
||||
python label-pr.py 1234 -r -d
|
||||
python label-pr.py 1234 --config custom-config.json
|
||||
python label-pr.py 1234 '[]'
|
||||
python label-pr.py 1234 '[{"name":"label1"}]' -a
|
||||
python label-pr.py 1234 '[{"name":"label1"}]' --replace
|
||||
python label-pr.py 1234 '[{"name":"label1"}]' -r -d
|
||||
python label-pr.py 1234 '[]' --config custom-config.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -42,9 +43,6 @@ def load_config_json(config_file: str) -> dict:
|
||||
print(f"✅ Loaded config from: {config_file}")
|
||||
|
||||
valid_config = True
|
||||
if not config.get("catch_all_label"):
|
||||
print("❌ Missing 'catch_all_label' in config file")
|
||||
valid_config = False
|
||||
if not config.get("title_patterns"):
|
||||
print("❌ Missing 'title_patterns' in config file")
|
||||
valid_config = False
|
||||
@@ -131,7 +129,7 @@ def label_filepaths(changed_files: list[str], path_patterns: dict) -> list[str]:
|
||||
labels_to_apply.remove("app:shared")
|
||||
|
||||
if not labels_to_apply:
|
||||
print("::warning::No matching file paths found, no labels applied.")
|
||||
print("::notice::No matching file paths found.")
|
||||
|
||||
return list(labels_to_apply)
|
||||
|
||||
@@ -151,10 +149,31 @@ def label_title(pr_title: str, title_patterns: dict) -> list[str]:
|
||||
break
|
||||
|
||||
if not labels_to_apply:
|
||||
print("::warning::No matching title patterns found, no labels applied.")
|
||||
print("::notice::No matching title patterns found.")
|
||||
|
||||
return list(labels_to_apply)
|
||||
|
||||
def parse_pr_labels(pr_labels_str: str) -> list[str]:
|
||||
"""Parse PR labels from JSON array string."""
|
||||
try:
|
||||
labels = json.loads(pr_labels_str)
|
||||
if not isinstance(labels, list):
|
||||
print("::warning::Failed to parse PR labels: not a list")
|
||||
return []
|
||||
return [item.get("name") for item in labels if item.get("name")]
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
print(f"::error::Error parsing PR labels: {e}")
|
||||
return []
|
||||
|
||||
def get_preserved_labels(pr_labels_str: str) -> list[str]:
|
||||
"""Get existing PR labels that should be preserved (exclude app: and t: labels)."""
|
||||
existing_labels = parse_pr_labels(pr_labels_str)
|
||||
print(f"🔍 Parsed PR labels: {existing_labels}")
|
||||
preserved_labels = [label for label in existing_labels if not (label.startswith("app:") or label.startswith("t:"))]
|
||||
if preserved_labels:
|
||||
print(f"🔍 Preserving existing labels: {', '.join(preserved_labels)}")
|
||||
return preserved_labels
|
||||
|
||||
def parse_args():
|
||||
"""Parse command line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
@@ -165,6 +184,11 @@ def parse_args():
|
||||
help="The pull request number"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"pr_labels",
|
||||
help="Current PR labels (JSON array)"
|
||||
)
|
||||
|
||||
mode_group = parser.add_mutually_exclusive_group()
|
||||
mode_group.add_argument(
|
||||
"-a", "--add",
|
||||
@@ -194,7 +218,6 @@ def parse_args():
|
||||
def main():
|
||||
args = parse_args()
|
||||
config = load_config_json(args.config)
|
||||
CATCH_ALL_LABEL = config["catch_all_label"]
|
||||
LABEL_TITLE_PATTERNS = config["title_patterns"]
|
||||
LABEL_PATH_PATTERNS = config["path_patterns"]
|
||||
|
||||
@@ -216,21 +239,23 @@ def main():
|
||||
title_labels = label_title(pr_title, LABEL_TITLE_PATTERNS)
|
||||
all_labels = set(filepath_labels + title_labels)
|
||||
|
||||
if not any(label.startswith("t:") for label in all_labels):
|
||||
all_labels.add(CATCH_ALL_LABEL)
|
||||
|
||||
if all_labels:
|
||||
print("--------------------------------")
|
||||
labels_str = ', '.join(sorted(all_labels))
|
||||
if mode == "add":
|
||||
print(f"🏷️ Adding labels: {labels_str}")
|
||||
print(f"::notice::🏷️ Adding labels: {labels_str}")
|
||||
if not args.dry_run:
|
||||
gh_add_labels(pr_number, list(all_labels))
|
||||
else:
|
||||
print(f"🏷️ Replacing labels with: {labels_str}")
|
||||
preserved_labels = get_preserved_labels(args.pr_labels)
|
||||
if preserved_labels:
|
||||
all_labels.update(preserved_labels)
|
||||
labels_str = ', '.join(sorted(all_labels))
|
||||
print(f"::notice::🏷️ Replacing labels with: {labels_str}")
|
||||
if not args.dry_run:
|
||||
gh_replace_labels(pr_number, list(all_labels))
|
||||
else:
|
||||
print("ℹ️ No matching patterns found, no labels applied.")
|
||||
print("::warning::No matching patterns found, no labels applied.")
|
||||
|
||||
print("✅ Done")
|
||||
|
||||
|
||||
4
.github/workflows/_version.yml
vendored
4
.github/workflows/_version.yml
vendored
@@ -79,7 +79,7 @@ jobs:
|
||||
|
||||
- name: Check out repository
|
||||
if: ${{ !inputs.skip_checkout || false }}
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload version info artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: version-info
|
||||
path: version_info.json
|
||||
|
||||
54
.github/workflows/build-authenticator.yml
vendored
54
.github/workflows/build-authenticator.yml
vendored
@@ -21,17 +21,19 @@ on:
|
||||
distribute-to-firebase:
|
||||
description: "Optional. Distribute artifacts to Firebase."
|
||||
required: false
|
||||
default: false
|
||||
default: true
|
||||
type: boolean
|
||||
publish-to-play-store:
|
||||
description: "Optional. Deploy bundle artifact to Google Play Store"
|
||||
required: false
|
||||
default: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
JAVA_VERSION: 21
|
||||
DISTRIBUTE_TO_FIREBASE: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
PUBLISH_TO_PLAY_STORE: ${{ inputs.publish-to-play-store || github.event_name == 'push' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -59,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
|
||||
|
||||
@@ -67,7 +69,7 @@ jobs:
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -77,7 +79,7 @@ jobs:
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
@@ -98,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
|
||||
|
||||
@@ -123,7 +124,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 +135,6 @@ jobs:
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
--name com.bitwarden.authenticator.dev-google-services.json --file ${{ github.workspace }}/authenticator/src/debug/google-services.json --output none
|
||||
|
||||
- name: Download Firebase credentials
|
||||
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
|
||||
env:
|
||||
ACCOUNT_NAME: bitwardenci
|
||||
CONTAINER_NAME: mobile
|
||||
@@ -184,7 +184,7 @@ jobs:
|
||||
--name authenticator_play_firebase-creds.json --file ${{ github.workspace }}/secrets/authenticator_play_firebase-creds.json --output none
|
||||
|
||||
- name: Download Play Store credentials
|
||||
if: ${{ inputs.publish-to-play-store }}
|
||||
if: ${{ env.PUBLISH_TO_PLAY_STORE }}
|
||||
env:
|
||||
ACCOUNT_NAME: bitwardenci
|
||||
CONTAINER_NAME: mobile
|
||||
@@ -198,7 +198,7 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Verify Play Store credentials
|
||||
if: ${{ inputs.publish-to-play-store }}
|
||||
if: ${{ env.PUBLISH_TO_PLAY_STORE }}
|
||||
run: |
|
||||
bundle exec fastlane run validate_play_store_json_key \
|
||||
json_key:"${{ github.workspace }}/secrets/authenticator_play_store-creds.json"
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -217,7 +217,7 @@ jobs:
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
@@ -283,17 +283,17 @@ jobs:
|
||||
keyAlias:"bitwardenauthenticator" \
|
||||
keyPassword:"$KEY_PASSWORD"
|
||||
|
||||
- name: Upload release Play Store .aab artifact
|
||||
- name: Upload to GitHub Artifacts - prod.aab
|
||||
if: ${{ matrix.variant == 'aab' }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.bitwarden.authenticator.aab
|
||||
path: authenticator/build/outputs/bundle/release/com.bitwarden.authenticator.aab
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload release .apk artifact
|
||||
- name: Upload to GitHub Artifacts - prod.apk
|
||||
if: ${{ matrix.variant == 'apk' }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.bitwarden.authenticator.apk
|
||||
path: authenticator/build/outputs/apk/release/com.bitwarden.authenticator.apk
|
||||
@@ -311,38 +311,36 @@ jobs:
|
||||
sha256sum "authenticator/build/outputs/apk/release/com.bitwarden.authenticator.apk" \
|
||||
> ./authenticator-android-apk-sha256.txt
|
||||
|
||||
- name: Upload .apk SHA file for release
|
||||
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
|
||||
if: ${{ matrix.variant == 'apk' }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: authenticator-android-apk-sha256.txt
|
||||
path: ./authenticator-android-apk-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload .aab SHA file for release
|
||||
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
|
||||
if: ${{ matrix.variant == 'aab' }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: authenticator-android-aab-sha256.txt
|
||||
path: ./authenticator-android-aab-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Install Firebase app distribution plugin
|
||||
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
if: ${{ matrix.variant == 'aab' && env.DISTRIBUTE_TO_FIREBASE }}
|
||||
run: bundle exec fastlane add_plugin firebase_app_distribution
|
||||
|
||||
- name: Publish release bundle to Firebase
|
||||
if: ${{ matrix.variant == 'aab' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
|
||||
- name: Distribute to Firebase - prod.aab
|
||||
if: ${{ matrix.variant == 'aab' && env.DISTRIBUTE_TO_FIREBASE }}
|
||||
env:
|
||||
FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/authenticator_play_firebase-creds.json
|
||||
run: |
|
||||
bundle exec fastlane distributeAuthenticatorReleaseBundleToFirebase \
|
||||
serviceCredentialsFile:"$FIREBASE_CREDS_PATH"
|
||||
|
||||
# Only publish bundles to Play Store when `publish-to-play-store` is true while building
|
||||
# bundles
|
||||
- name: Publish release bundle to Google Play Store
|
||||
if: ${{ inputs.publish-to-play-store && matrix.variant == 'aab' }}
|
||||
- name: Publish to Play Store - prod.aab
|
||||
if: ${{ matrix.variant == 'aab' && env.PUBLISH_TO_PLAY_STORE }}
|
||||
env:
|
||||
PLAY_STORE_CREDS_FILE: ${{ github.workspace }}/secrets/authenticator_play_store-creds.json
|
||||
run: |
|
||||
|
||||
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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Increment version
|
||||
env:
|
||||
DEFAULT_VERSION_CODE: ${{ github.run_number }}
|
||||
INPUT_VERSION_CODE: "${{ needs.version.outputs.version_number }}"
|
||||
INPUT_VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: |
|
||||
VERSION_CODE="${INPUT_VERSION_CODE:-$DEFAULT_VERSION_CODE}"
|
||||
VERSION_NAME_INPUT="${INPUT_VERSION_NAME:-}"
|
||||
bundle exec fastlane setBuildVersionInfo \
|
||||
versionCode:"$VERSION_CODE" \
|
||||
versionName:"$VERSION_NAME_INPUT"
|
||||
|
||||
regex='appVersionName = "(.+)"'
|
||||
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
|
||||
VERSION_NAME="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Build Test Harness Debug APK
|
||||
run: ./gradlew :testharness:assembleDebug
|
||||
|
||||
- name: Upload Test Harness APK
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.bitwarden.testharness.dev-debug.apk
|
||||
path: testharness/build/outputs/apk/debug/com.bitwarden.testharness.dev.apk
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create checksum for Test Harness APK
|
||||
run: |
|
||||
sha256sum "testharness/build/outputs/apk/debug/com.bitwarden.testharness.dev.apk" \
|
||||
> ./com.bitwarden.testharness.dev.apk-sha256.txt
|
||||
|
||||
- name: Upload Test Harness SHA file
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.bitwarden.testharness.dev.apk-sha256.txt
|
||||
path: ./com.bitwarden.testharness.dev.apk-sha256.txt
|
||||
if-no-files-found: error
|
||||
107
.github/workflows/build.yml
vendored
107
.github/workflows/build.yml
vendored
@@ -33,6 +33,8 @@ env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
JAVA_VERSION: 21
|
||||
GITHUB_ACTION_RUN_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
DISTRIBUTE_TO_FIREBASE: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
PUBLISH_TO_PLAY_STORE: ${{ inputs.publish-to-play-store || github.event_name == 'push' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -61,7 +63,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
|
||||
|
||||
@@ -69,7 +71,7 @@ jobs:
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -79,7 +81,7 @@ jobs:
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
@@ -100,7 +102,6 @@ jobs:
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
@@ -111,7 +112,7 @@ jobs:
|
||||
run: bundle exec fastlane assembleDebugApks
|
||||
|
||||
- name: Upload test reports on failure
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: failure()
|
||||
with:
|
||||
name: test-reports
|
||||
@@ -132,7 +133,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 +144,6 @@ jobs:
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
--name google-services.json --file ${{ github.workspace }}/app/src/standardBeta/google-services.json --output none
|
||||
|
||||
- name: Download Firebase credentials
|
||||
if: ${{ matrix.variant == 'prod' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
|
||||
if: ${{ matrix.variant == 'prod' && env.DISTRIBUTE_TO_FIREBASE }}
|
||||
env:
|
||||
ACCOUNT_NAME: bitwardenci
|
||||
CONTAINER_NAME: mobile
|
||||
@@ -203,7 +203,7 @@ jobs:
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -213,7 +213,7 @@ jobs:
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
@@ -297,42 +297,42 @@ jobs:
|
||||
run: |
|
||||
bundle exec fastlane assembleDebugApks
|
||||
|
||||
- name: Upload release Play Store .aab artifact
|
||||
- name: Upload to GitHub Artifacts - prod.aab
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.aab
|
||||
path: app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden.aab
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload beta Play Store .aab artifact
|
||||
- name: Upload to GitHub Artifacts - beta.aab
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.aab
|
||||
path: app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden.beta.aab
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload release .apk artifact
|
||||
- name: Upload to GitHub Artifacts - prod.apk
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.apk
|
||||
path: app/build/outputs/apk/standard/release/com.x8bit.bitwarden.apk
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload beta .apk artifact
|
||||
- name: Upload to GitHub Artifacts - beta.apk
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.apk
|
||||
path: app/build/outputs/apk/standard/beta/com.x8bit.bitwarden.beta.apk
|
||||
if-no-files-found: error
|
||||
|
||||
# When building variants other than 'prod'
|
||||
- name: Upload debug .apk artifact
|
||||
- name: Upload to GitHub Artifacts - dev.apk
|
||||
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk
|
||||
path: app/build/outputs/apk/standard/debug/com.x8bit.bitwarden.dev.apk
|
||||
@@ -368,52 +368,52 @@ jobs:
|
||||
sha256sum "app/build/outputs/apk/standard/debug/com.x8bit.bitwarden.dev.apk" \
|
||||
> ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
|
||||
|
||||
- name: Upload .apk SHA file for release
|
||||
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.apk-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload .apk SHA file for beta
|
||||
- name: Upload to GitHub Artifacts - beta.apk-sha256.txt
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.beta.apk-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload .aab SHA file for release
|
||||
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.aab-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.aab-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload .aab SHA file for beta
|
||||
- name: Upload to GitHub Artifacts - beta.aab-sha256.txt
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.aab-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.beta.aab-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload .apk SHA file for debug
|
||||
- name: Upload to GitHub Artifacts - debug.apk-sha256.txt
|
||||
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Install Firebase app distribution plugin
|
||||
if: ${{ matrix.variant == 'prod' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && env.DISTRIBUTE_TO_FIREBASE }}
|
||||
run: bundle exec fastlane add_plugin firebase_app_distribution
|
||||
|
||||
- name: Publish release artifacts to Firebase
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
|
||||
- name: Distribute to Firebase - prod.apk
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && env.DISTRIBUTE_TO_FIREBASE }}
|
||||
env:
|
||||
APP_PLAY_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json
|
||||
run: |
|
||||
@@ -421,8 +421,8 @@ jobs:
|
||||
actionUrl:$GITHUB_ACTION_RUN_URL \
|
||||
service_credentials_file:$APP_PLAY_FIREBASE_CREDS_PATH
|
||||
|
||||
- name: Publish beta artifacts to Firebase
|
||||
if: ${{ (matrix.variant == 'prod' && matrix.artifact == 'apk') && (inputs.distribute-to-firebase || github.event_name == 'push') }}
|
||||
- name: Distribute to Firebase - beta.apk
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && env.DISTRIBUTE_TO_FIREBASE }}
|
||||
env:
|
||||
APP_PLAY_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json
|
||||
run: |
|
||||
@@ -431,12 +431,12 @@ jobs:
|
||||
service_credentials_file:$APP_PLAY_FIREBASE_CREDS_PATH
|
||||
|
||||
- name: Verify Play Store credentials
|
||||
if: ${{ matrix.variant == 'prod' && inputs.publish-to-play-store }}
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && env.PUBLISH_TO_PLAY_STORE }}
|
||||
run: |
|
||||
bundle exec fastlane run validate_play_store_json_key
|
||||
|
||||
- name: Publish Play Store bundle
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && (inputs.publish-to-play-store || github.event_name == 'push') }}
|
||||
- name: Publish to Play Store - prod.aab
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && env.PUBLISH_TO_PLAY_STORE }}
|
||||
run: |
|
||||
bundle exec fastlane publishProdToPlayStore
|
||||
bundle exec fastlane publishBetaToPlayStore
|
||||
@@ -451,7 +451,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -462,7 +462,6 @@ jobs:
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
@@ -491,7 +490,7 @@ jobs:
|
||||
--name app_beta_fdroid-keystore.jks --file ${{ github.workspace }}/keystores/app_beta_fdroid-keystore.jks --output none
|
||||
|
||||
- name: Download Firebase credentials
|
||||
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
|
||||
env:
|
||||
ACCOUNT_NAME: bitwardenci
|
||||
CONTAINER_NAME: mobile
|
||||
@@ -508,7 +507,7 @@ jobs:
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -518,7 +517,7 @@ jobs:
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
@@ -578,8 +577,8 @@ jobs:
|
||||
keyAlias:bitwarden-beta \
|
||||
keyPassword:$FDROID_BETA_KEY_PASSWORD
|
||||
|
||||
- name: Upload F-Droid .apk artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
- name: Upload to GitHub Artifacts - fdroid.apk
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden-fdroid.apk
|
||||
path: app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid.apk
|
||||
@@ -590,15 +589,15 @@ jobs:
|
||||
sha256sum "app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid.apk" \
|
||||
> ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
|
||||
|
||||
- name: Upload F-Droid SHA file
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
- name: Upload to GitHub Artifacts - fdroid.apk-sha256.txt
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden-fdroid.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload F-Droid Beta .apk artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
- name: Upload to GitHub Artifacts - beta.fdroid.apk
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta-fdroid.apk
|
||||
path: app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden.beta-fdroid.apk
|
||||
@@ -609,19 +608,19 @@ jobs:
|
||||
sha256sum "app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden.beta-fdroid.apk" \
|
||||
> ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
|
||||
|
||||
- name: Upload F-Droid Beta SHA file
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
- name: Upload to GitHub Artifacts - beta.fdroid.apk-sha256.txt
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Install Firebase app distribution plugin
|
||||
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
|
||||
run: bundle exec fastlane add_plugin firebase_app_distribution
|
||||
|
||||
- name: Publish release F-Droid artifacts to Firebase
|
||||
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
- name: Distribute to Firebase - fdroid.apk
|
||||
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
|
||||
env:
|
||||
APP_FDROID_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_fdroid_firebase-creds.json
|
||||
run: |
|
||||
|
||||
@@ -2,8 +2,8 @@ name: Cron / Sync Google Privileged Browsers List
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run weekly on 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
|
||||
|
||||
@@ -96,4 +96,4 @@ jobs:
|
||||
--base main \
|
||||
--head "$BRANCH_NAME" \
|
||||
--label "automated-pr" \
|
||||
--label "t:ci"
|
||||
--label "t:deps"
|
||||
|
||||
13
.github/workflows/crowdin-pull.yml
vendored
13
.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
|
||||
@@ -69,5 +73,6 @@ jobs:
|
||||
create_pull_request: true
|
||||
pull_request_title: "Crowdin Pull"
|
||||
pull_request_body: ":inbox_tray: New translations received!"
|
||||
pull_request_labels: "automated-pr, t:misc"
|
||||
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
|
||||
gpg_passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
|
||||
|
||||
2
.github/workflows/crowdin-push.yml
vendored
2
.github/workflows/crowdin-push.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@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/review-code.yml
vendored
2
.github/workflows/review-code.yml
vendored
@@ -2,7 +2,7 @@ name: Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions: {}
|
||||
|
||||
|
||||
26
.github/workflows/sdlc-label-pr.yml
vendored
26
.github/workflows/sdlc-label-pr.yml
vendored
@@ -1,6 +1,8 @@
|
||||
name: SDLC / Label PR by Files
|
||||
|
||||
name: SDLC / Label PR
|
||||
run-name: Label PR ${{ github.event.pull_request.number || inputs.pr-number }}${{ github.event_name == 'workflow_dispatch' && format(' / mode "{0}" dry-run "{1}"', inputs.mode, inputs.dry-run) || '' }}
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr-number:
|
||||
@@ -19,6 +21,9 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
env:
|
||||
_PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr-number }}
|
||||
|
||||
jobs:
|
||||
label-pr:
|
||||
name: Label PR by Changed Files
|
||||
@@ -29,7 +34,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
|
||||
|
||||
@@ -37,7 +42,6 @@ jobs:
|
||||
id: label-mode
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
_PR_NUMBER: ${{ inputs.pr-number }}
|
||||
_PR_USER: ${{ github.event.pull_request.user.login }}
|
||||
_IS_FORK: ${{ github.event.pull_request.head.repo.fork }}
|
||||
run: |
|
||||
@@ -59,7 +63,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$_PR_USER" = "renovate[bot]" ] || [ "$_PR_USER" = "bw-ghapp[bot]" ]; then
|
||||
if [[ "$_PR_USER" == app/* || "$_PR_USER" == *\[bot\] ]]; then
|
||||
echo "➡️ Bot PR ($_PR_USER). Label mode: --add"
|
||||
echo "label_mode=--add" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
@@ -71,10 +75,16 @@ jobs:
|
||||
- name: Label PR based on changed files
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
_PR_NUMBER: ${{ inputs.pr-number || github.event.pull_request.number }}
|
||||
_LABEL_MODE: ${{ inputs.mode && format('--{0}', inputs.mode) || steps.label-mode.outputs.label_mode }}
|
||||
_DRY_RUN: ${{ inputs.dry-run == true && '--dry-run' || '' }}
|
||||
_PR_LABELS: ${{ toJSON(github.event.pull_request.labels) }}
|
||||
run: |
|
||||
echo "🔍 Labeling PR #$_PR_NUMBER with mode: $_LABEL_MODE and dry-run: $_DRY_RUN"
|
||||
python3 .github/scripts/label-pr.py "$_PR_NUMBER" "$_LABEL_MODE" "$_DRY_RUN"
|
||||
if [ -z "$_PR_LABELS" ] || [ "$_PR_LABELS" = "null" ] || [ "$_PR_LABELS" = "[]" ]; then
|
||||
echo "🔍 No current PR labels found, retrieving PR data for PR #$_PR_NUMBER..."
|
||||
_PR_LABELS=$(gh pr view "$_PR_NUMBER" --json labels --jq '.labels')
|
||||
fi
|
||||
echo "🔍 Labeling PR #$_PR_NUMBER with mode: \"$_LABEL_MODE\" and dry-run: \"$_DRY_RUN\" and current PR labels: \"$_PR_LABELS\"..."
|
||||
echo "🐍 Running label-pr.py script..."
|
||||
echo ""
|
||||
python3 .github/scripts/label-pr.py "$_PR_NUMBER" "$_PR_LABELS" "$_LABEL_MODE" "$_DRY_RUN"
|
||||
|
||||
|
||||
6
.github/workflows/sdlc-sdk-update.yml
vendored
6
.github/workflows/sdlc-sdk-update.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
permission-contents: write
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
fetch-depth: 0
|
||||
@@ -190,7 +190,7 @@ jobs:
|
||||
--base main \
|
||||
--head "$_BRANCH_NAME" \
|
||||
--label "automated-pr" \
|
||||
--label "t:ci")
|
||||
--label "t:deps")
|
||||
echo "## 🚀 Created PR: $PR_URL" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
9
.github/workflows/test.yml
vendored
9
.github/workflows/test.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
@@ -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
|
||||
|
||||
@@ -76,7 +75,7 @@ jobs:
|
||||
bundle exec fastlane check
|
||||
|
||||
- name: Upload test reports
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: test-reports
|
||||
|
||||
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'
|
||||
|
||||
49
Gemfile.lock
49
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.1206.0)
|
||||
aws-sdk-core (3.241.4)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@@ -20,25 +17,25 @@ 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.121.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.203.0)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
aws-sdk-s3 (1.212.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.3.0)
|
||||
bigdecimal (3.3.1)
|
||||
bigdecimal (4.0.1)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.5)
|
||||
date (3.5.0)
|
||||
date (3.5.1)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
@@ -58,14 +55,14 @@ 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)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.1.1)
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (1.0.2)
|
||||
faraday-net_http_persistent (1.2.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,23 +169,23 @@ GEM
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.16.0)
|
||||
json (2.18.0)
|
||||
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.19.1)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
nkf (0.2.0)
|
||||
optparse (0.8.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
public_suffix (6.0.2)
|
||||
public_suffix (7.0.2)
|
||||
rake (13.3.1)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
@@ -209,7 +209,7 @@ GEM
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
time (0.4.1)
|
||||
time (0.4.2)
|
||||
date
|
||||
trailblazer-option (0.1.2)
|
||||
tty-cursor (0.7.1)
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "2835802f9de260f6f5109c81081e9b46",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "organization_events",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `organization_event_type` TEXT NOT NULL, `cipher_id` TEXT, `date` INTEGER NOT NULL, `organization_id` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "organizationEventType",
|
||||
"columnName": "organization_event_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cipherId",
|
||||
"columnName": "cipher_id",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "date",
|
||||
"columnName": "date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "organizationId",
|
||||
"columnName": "organization_id",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_organization_events_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_organization_events_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2835802f9de260f6f5109c81081e9b46')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 9,
|
||||
"identityHash": "61353072161e3101ade140e2c4b65495",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "ciphers",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `has_totp` INTEGER NOT NULL DEFAULT 1, `cipher_type` TEXT NOT NULL, `cipher_json` TEXT NOT NULL, `organization_id` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasTotp",
|
||||
"columnName": "has_totp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "cipherType",
|
||||
"columnName": "cipher_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cipherJson",
|
||||
"columnName": "cipher_json",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "organizationId",
|
||||
"columnName": "organization_id",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_ciphers_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_ciphers_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
},
|
||||
{
|
||||
"name": "index_ciphers_user_id_organization_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id",
|
||||
"organization_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_ciphers_user_id_organization_id` ON `${TABLE_NAME}` (`user_id`, `organization_id`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "collections",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `organization_id` TEXT NOT NULL, `should_hide_passwords` INTEGER NOT NULL, `name` TEXT NOT NULL, `external_id` TEXT, `read_only` INTEGER NOT NULL, `manage` INTEGER, `default_user_collection_email` TEXT, `type` TEXT NOT NULL DEFAULT '0', PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "organizationId",
|
||||
"columnName": "organization_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "shouldHidePasswords",
|
||||
"columnName": "should_hide_passwords",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "externalId",
|
||||
"columnName": "external_id",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isReadOnly",
|
||||
"columnName": "read_only",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "canManage",
|
||||
"columnName": "manage",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "defaultUserCollectionEmail",
|
||||
"columnName": "default_user_collection_email",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'0'"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_collections_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "domains",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `domains_json` TEXT, PRIMARY KEY(`user_id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "domainsJson",
|
||||
"columnName": "domains_json",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `name` TEXT, `revision_date` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "revisionDate",
|
||||
"columnName": "revision_date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_folders_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_folders_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "sends",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `send_type` TEXT NOT NULL, `send_json` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sendType",
|
||||
"columnName": "send_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sendJson",
|
||||
"columnName": "send_json",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_sends_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_sends_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '61353072161e3101ade140e2c4b65495')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -13,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" />
|
||||
@@ -139,6 +140,19 @@
|
||||
android:launchMode="singleTop"
|
||||
android:noHistory="true"
|
||||
android:theme="@android:style/Theme.NoDisplay">
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="https" />
|
||||
<data android:host="bitwarden.com" />
|
||||
<data android:host="bitwarden.eu" />
|
||||
<data android:pathPattern="/duo-callback" />
|
||||
<data android:pathPattern="/sso-callback" />
|
||||
<data android:pathPattern="/webauthn-callback" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
@@ -261,7 +275,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>
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
{
|
||||
"apps": [
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "eu.weblibre.gecko",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "BB:2A:97:F5:61:53:35:C9:E5:7C:86:6F:1C:30:ED:4F:D7:D7:BD:DC:BC:BC:06:68:FE:93:A5:79:17:3D:3D:2D"
|
||||
},
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "8F:52:6E:1E:53:D6:BD:4D:FB:F4:F4:B9:3C:2A:91:EC:B5:CB:8D:A5:E1:4A:D9:4C:25:70:E1:E3:C7:13:52:7F"
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
@@ -12,18 +28,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "org.chromium.chrome",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "A8:56:48:50:79:BC:B3:57:BF:BE:69:BA:19:A9:BA:43:CD:0A:D9:AB:22:67:52:C7:80:B6:88:8A:FD:48:21:6B"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
|
||||
@@ -159,11 +159,11 @@ class AuthDiskSourceImpl(
|
||||
storeAuthenticatorSyncUnlockKey(userId = userId, authenticatorSyncUnlockKey = null)
|
||||
storeShowImportLogins(userId = userId, showImportLogins = null)
|
||||
storeLastLockTimestamp(userId = userId, lastLockTimestamp = null)
|
||||
storeEncryptedPin(userId = userId, encryptedPin = null)
|
||||
storePinProtectedUserKey(userId = userId, pinProtectedUserKey = null)
|
||||
storePinProtectedUserKeyEnvelope(userId = userId, pinProtectedUserKeyEnvelope = null)
|
||||
|
||||
// Certain values are never removed as required by the feature requirements:
|
||||
// * EncryptedPin
|
||||
// * PinProtectedUserKey
|
||||
// * PinProtectedUserKeyEnvelope
|
||||
// * DeviceKey
|
||||
// * PendingAuthRequest
|
||||
// * OnboardingStatus
|
||||
@@ -373,7 +373,10 @@ class AuthDiskSourceImpl(
|
||||
inMemoryOnly: Boolean,
|
||||
) {
|
||||
inMemoryPinProtectedUserKeyEnvelopes[userId] = pinProtectedUserKeyEnvelope
|
||||
if (inMemoryOnly) return
|
||||
if (inMemoryOnly) {
|
||||
getMutablePinProtectedUserKeyEnvelopeFlow(userId).tryEmit(pinProtectedUserKeyEnvelope)
|
||||
return
|
||||
}
|
||||
putString(
|
||||
key = PIN_PROTECTED_USER_KEY_KEY_ENVELOPE.appendIdentifier(userId),
|
||||
value = pinProtectedUserKeyEnvelope,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.RevokeFromOrganizationResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
|
||||
@@ -38,6 +39,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 +50,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
interface AuthRepository :
|
||||
AuthenticatorProvider,
|
||||
AuthRequestManager,
|
||||
BiometricsEncryptionManager,
|
||||
KdfManager,
|
||||
UserStateManager {
|
||||
/**
|
||||
@@ -357,14 +360,14 @@ interface AuthRepository :
|
||||
suspend fun getPasswordStrength(email: String? = null, password: String): PasswordStrengthResult
|
||||
|
||||
/**
|
||||
* Validates the master password for the current logged in user.
|
||||
* Validates the master password for the current logged-in user.
|
||||
*/
|
||||
suspend fun validatePassword(password: String): ValidatePasswordResult
|
||||
|
||||
/**
|
||||
* Validates the PIN for the current logged in user.
|
||||
* Validates the PIN for the current logged-in user.
|
||||
*/
|
||||
suspend fun validatePin(pin: String): ValidatePinResult
|
||||
suspend fun validatePinUserKey(pin: String): ValidatePinResult
|
||||
|
||||
/**
|
||||
* Validates the given [password] against the master password
|
||||
@@ -400,4 +403,11 @@ interface AuthRepository :
|
||||
suspend fun leaveOrganization(
|
||||
organizationId: String,
|
||||
): LeaveOrganizationResult
|
||||
|
||||
/**
|
||||
* Revokes self from the organization that matches the given [organizationId]
|
||||
*/
|
||||
suspend fun revokeFromOrganization(
|
||||
organizationId: String,
|
||||
): RevokeFromOrganizationResult
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ package com.x8bit.bitwarden.data.auth.repository
|
||||
|
||||
import com.bitwarden.core.AuthRequestMethod
|
||||
import com.bitwarden.core.InitUserCryptoMethod
|
||||
import com.bitwarden.core.RegisterTdeKeyResponse
|
||||
import com.bitwarden.core.WrappedAccountCryptographicState
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.core.data.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
|
||||
@@ -12,6 +15,7 @@ import com.bitwarden.crypto.Kdf
|
||||
import com.bitwarden.data.datasource.disk.ConfigDiskSource
|
||||
import com.bitwarden.data.repository.util.toEnvironmentUrls
|
||||
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
|
||||
import com.bitwarden.network.model.CreateAccountKeysResponseJson
|
||||
import com.bitwarden.network.model.DeleteAccountResponseJson
|
||||
import com.bitwarden.network.model.GetTokenResponseJson
|
||||
import com.bitwarden.network.model.IdentityTokenAuthModel
|
||||
@@ -77,6 +81,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.RevokeFromOrganizationResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
|
||||
@@ -100,8 +105,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
|
||||
@@ -112,6 +117,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockError
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.createWrappedAccountCryptographicState
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.toSdkMasterPasswordUnlock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -157,6 +163,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 +175,7 @@ class AuthRepositoryImpl(
|
||||
dispatcherManager: DispatcherManager,
|
||||
) : AuthRepository,
|
||||
AuthRequestManager by authRequestManager,
|
||||
BiometricsEncryptionManager by biometricsEncryptionManager,
|
||||
KdfManager by kdfManager,
|
||||
UserStateManager by userStateManager {
|
||||
/**
|
||||
@@ -454,42 +462,32 @@ class AuthRepositoryImpl(
|
||||
.getShouldTrustDevice(userId = userId) == true,
|
||||
)
|
||||
}
|
||||
.flatMap { keys ->
|
||||
.flatMap { registerTdeKeyResponse ->
|
||||
accountsService
|
||||
.createAccountKeys(
|
||||
publicKey = keys.publicKey,
|
||||
encryptedPrivateKey = keys.privateKey,
|
||||
publicKey = registerTdeKeyResponse.publicKey,
|
||||
encryptedPrivateKey = registerTdeKeyResponse.privateKey,
|
||||
)
|
||||
.map { keys }
|
||||
.map { createAccountKeysResponse ->
|
||||
registerTdeKeyResponse to createAccountKeysResponse
|
||||
}
|
||||
}
|
||||
.flatMap { keys ->
|
||||
.flatMap { (registerTdeKeyResponse, createAccountKeysResponse) ->
|
||||
organizationService
|
||||
.organizationResetPasswordEnroll(
|
||||
organizationId = orgAutoEnrollStatus.organizationId,
|
||||
userId = userId,
|
||||
passwordHash = null,
|
||||
resetPasswordKey = keys.adminReset,
|
||||
resetPasswordKey = registerTdeKeyResponse.adminReset,
|
||||
)
|
||||
.map { keys }
|
||||
.map { registerTdeKeyResponse to createAccountKeysResponse }
|
||||
}
|
||||
.onSuccess { keys ->
|
||||
// TDE and SSO user creation still uses crypto-v1. These users are not
|
||||
// expected to have the AEAD keys so we only store the private key for now.
|
||||
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
|
||||
// for more details.
|
||||
authDiskSource.storePrivateKey(
|
||||
.onSuccess { (registerTdeKeyResponse, createAccountKeysResponse) ->
|
||||
createNewSsoUserSuccess(
|
||||
userId = userId,
|
||||
privateKey = keys.privateKey,
|
||||
createAccountKeysResponse = createAccountKeysResponse,
|
||||
registerTdeKeyResponse = registerTdeKeyResponse,
|
||||
)
|
||||
// Order matters here, we need to make sure that the vault is unlocked
|
||||
// before we trust the device, to avoid state-base navigation issues.
|
||||
vaultRepository.syncVaultState(userId = userId)
|
||||
keys.deviceKey?.let { trustDeviceResponse ->
|
||||
trustedDeviceManager.trustThisDevice(
|
||||
userId = userId,
|
||||
trustDeviceResponse = trustDeviceResponse,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.fold(
|
||||
@@ -498,6 +496,37 @@ class AuthRepositoryImpl(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores all the relevant data from a successful creation of an SSO user. The data is stored
|
||||
* while in an [UserStateManager.userStateTransaction] to ensure the `UserState` is only
|
||||
* updated once after data stored.
|
||||
*/
|
||||
private suspend fun createNewSsoUserSuccess(
|
||||
userId: String,
|
||||
createAccountKeysResponse: CreateAccountKeysResponseJson,
|
||||
registerTdeKeyResponse: RegisterTdeKeyResponse,
|
||||
): Unit = userStateManager.userStateTransaction {
|
||||
authDiskSource.storeAccountKeys(
|
||||
userId = userId,
|
||||
accountKeys = createAccountKeysResponse.accountKeys,
|
||||
)
|
||||
// TDE and SSO user creation still uses crypto-v1. These users are not
|
||||
// expected to have the AEAD keys so we only store the private key for now.
|
||||
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
|
||||
// for more details.
|
||||
authDiskSource.storePrivateKey(
|
||||
userId = userId,
|
||||
privateKey = registerTdeKeyResponse.privateKey,
|
||||
)
|
||||
vaultRepository.syncVaultState(userId = userId)
|
||||
registerTdeKeyResponse.deviceKey?.let { trustDeviceResponse ->
|
||||
trustedDeviceManager.trustThisDevice(
|
||||
userId = userId,
|
||||
trustDeviceResponse = trustDeviceResponse,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun completeTdeLogin(
|
||||
requestPrivateKey: String,
|
||||
asymmetricalKey: String,
|
||||
@@ -514,6 +543,7 @@ class AuthRepositoryImpl(
|
||||
)
|
||||
val signingKey = accountKeys?.signatureKeyPair?.wrappedSigningKey
|
||||
val securityState = accountKeys?.securityState?.securityState
|
||||
val signedPublicKey = accountKeys?.publicKeyEncryptionKeyPair?.signedPublicKey
|
||||
|
||||
checkForVaultUnlockError(
|
||||
onVaultUnlockError = { error ->
|
||||
@@ -521,10 +551,13 @@ class AuthRepositoryImpl(
|
||||
},
|
||||
) {
|
||||
unlockVault(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = securityState,
|
||||
signingKey = signingKey,
|
||||
signedPublicKey = signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
privateKey = privateKey,
|
||||
signingKey = signingKey,
|
||||
securityState = securityState,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
|
||||
requestPrivateKey = requestPrivateKey,
|
||||
method = AuthRequestMethod.UserKey(protectedUserKey = asymmetricalKey),
|
||||
@@ -1287,7 +1320,7 @@ class AuthRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun validatePin(pin: String): ValidatePinResult {
|
||||
override suspend fun validatePinUserKey(pin: String): ValidatePinResult {
|
||||
val activeAccount = authDiskSource
|
||||
.userState
|
||||
?.activeAccount
|
||||
@@ -1296,13 +1329,13 @@ class AuthRepositoryImpl(
|
||||
val pinProtectedUserKeyEnvelope = authDiskSource
|
||||
.getPinProtectedUserKeyEnvelope(userId = activeAccount.userId)
|
||||
?: return ValidatePinResult.Error(
|
||||
error = MissingPropertyException("Pin Protected User Key"),
|
||||
error = MissingPropertyException("Pin Protected User Key Envelope"),
|
||||
)
|
||||
return vaultSdkSource
|
||||
.validatePin(
|
||||
.validatePinUserKey(
|
||||
userId = activeAccount.userId,
|
||||
pin = pin,
|
||||
pinProtectedUserKey = pinProtectedUserKeyEnvelope,
|
||||
pinProtectedUserKeyEnvelope = pinProtectedUserKeyEnvelope,
|
||||
)
|
||||
.fold(
|
||||
onSuccess = { ValidatePinResult.Success(isValid = it) },
|
||||
@@ -1356,10 +1389,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
|
||||
@@ -1384,6 +1417,14 @@ class AuthRepositoryImpl(
|
||||
onFailure = { LeaveOrganizationResult.Error(error = it) },
|
||||
)
|
||||
|
||||
override suspend fun revokeFromOrganization(
|
||||
organizationId: String,
|
||||
): RevokeFromOrganizationResult =
|
||||
organizationService.revokeFromOrganization(organizationId).fold(
|
||||
onSuccess = { RevokeFromOrganizationResult.Success },
|
||||
onFailure = { RevokeFromOrganizationResult.Error(error = it) },
|
||||
)
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
private suspend fun validatePasswordAgainstPolicy(
|
||||
password: String,
|
||||
@@ -1806,14 +1847,23 @@ class AuthRepositoryImpl(
|
||||
)
|
||||
.map {
|
||||
unlockVault(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = loginResponse.accountKeys
|
||||
?.securityState
|
||||
?.securityState,
|
||||
signingKey = loginResponse.accountKeys
|
||||
?.signatureKeyPair
|
||||
?.wrappedSigningKey,
|
||||
signedPublicKey = loginResponse.accountKeys
|
||||
?.publicKeyEncryptionKeyPair
|
||||
?.signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
privateKey = privateKey,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
|
||||
masterKey = it.masterKey,
|
||||
userKey = key,
|
||||
),
|
||||
securityState = loginResponse.accountKeys?.securityState?.securityState,
|
||||
signingKey = loginResponse.accountKeys?.signatureKeyPair?.wrappedSigningKey,
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
@@ -1834,11 +1884,21 @@ class AuthRepositoryImpl(
|
||||
organizationIdentifier = orgIdentifier,
|
||||
)
|
||||
.map { keyConnectorResponse ->
|
||||
val accountKeys = loginResponse.accountKeys
|
||||
val result = unlockVault(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = keyConnectorResponse.keys.private,
|
||||
securityState = accountKeys
|
||||
?.securityState
|
||||
?.securityState,
|
||||
signingKey = accountKeys
|
||||
?.signatureKeyPair
|
||||
?.wrappedSigningKey,
|
||||
signedPublicKey = accountKeys
|
||||
?.publicKeyEncryptionKeyPair
|
||||
?.signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
privateKey = keyConnectorResponse.keys.private,
|
||||
securityState = loginResponse.accountKeys?.securityState?.securityState,
|
||||
signingKey = loginResponse.accountKeys?.signatureKeyPair?.wrappedSigningKey,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
|
||||
masterKey = keyConnectorResponse.masterKey,
|
||||
userKey = keyConnectorResponse.encryptedUserKey,
|
||||
@@ -1883,27 +1943,30 @@ 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(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = loginResponse.accountKeys
|
||||
?.securityState
|
||||
?.securityState,
|
||||
signingKey = loginResponse.accountKeys
|
||||
?.signatureKeyPair
|
||||
?.wrappedSigningKey,
|
||||
signedPublicKey = loginResponse.accountKeys
|
||||
?.publicKeyEncryptionKeyPair
|
||||
?.signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
privateKey = privateKey,
|
||||
securityState = loginResponse.accountKeys?.securityState?.securityState,
|
||||
signingKey = loginResponse.accountKeys?.signatureKeyPair?.wrappedSigningKey,
|
||||
initUserCryptoMethod = initUserCryptoMethod,
|
||||
)
|
||||
}
|
||||
@@ -1911,6 +1974,7 @@ class AuthRepositoryImpl(
|
||||
/**
|
||||
* Attempt to unlock the current user's vault with trusted device specific data.
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
private suspend fun unlockVaultWithTdeOnLoginSuccess(
|
||||
loginResponse: GetTokenResponseJson.Success,
|
||||
profile: AccountJson.Profile,
|
||||
@@ -1923,10 +1987,19 @@ class AuthRepositoryImpl(
|
||||
if (privateKey != null && key != null) {
|
||||
deviceData?.let { model ->
|
||||
return unlockVault(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = loginResponse.accountKeys
|
||||
?.securityState
|
||||
?.securityState,
|
||||
signingKey = loginResponse.accountKeys
|
||||
?.signatureKeyPair
|
||||
?.wrappedSigningKey,
|
||||
signedPublicKey = loginResponse.accountKeys
|
||||
?.publicKeyEncryptionKeyPair
|
||||
?.signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
privateKey = privateKey,
|
||||
securityState = loginResponse.accountKeys?.securityState?.securityState,
|
||||
signingKey = loginResponse.accountKeys?.signatureKeyPair?.wrappedSigningKey,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
|
||||
requestPrivateKey = model.privateKey,
|
||||
method = model
|
||||
@@ -1956,9 +2029,18 @@ class AuthRepositoryImpl(
|
||||
unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
|
||||
options = options,
|
||||
profile = profile,
|
||||
privateKey = accountKeys.publicKeyEncryptionKeyPair.wrappedPrivateKey,
|
||||
securityState = accountKeys.securityState?.securityState,
|
||||
signingKey = accountKeys.signatureKeyPair?.wrappedSigningKey,
|
||||
privateKey = accountKeys
|
||||
.publicKeyEncryptionKeyPair
|
||||
.wrappedPrivateKey,
|
||||
securityState = accountKeys
|
||||
.securityState
|
||||
?.securityState,
|
||||
signedPublicKey = accountKeys
|
||||
.publicKeyEncryptionKeyPair
|
||||
.signedPublicKey,
|
||||
signingKey = accountKeys
|
||||
.signatureKeyPair
|
||||
?.wrappedSigningKey,
|
||||
)
|
||||
}
|
||||
?: loginResponse.privateKey
|
||||
@@ -1968,6 +2050,7 @@ class AuthRepositoryImpl(
|
||||
profile = profile,
|
||||
privateKey = privateKey,
|
||||
securityState = null,
|
||||
signedPublicKey = null,
|
||||
signingKey = null,
|
||||
)
|
||||
}
|
||||
@@ -1983,6 +2066,7 @@ class AuthRepositoryImpl(
|
||||
profile: AccountJson.Profile,
|
||||
privateKey: String,
|
||||
securityState: String?,
|
||||
signedPublicKey: String?,
|
||||
signingKey: String?,
|
||||
): VaultUnlockResult? {
|
||||
var vaultUnlockResult: VaultUnlockResult? = null
|
||||
@@ -2000,10 +2084,13 @@ class AuthRepositoryImpl(
|
||||
// For approved requests the key will always be present.
|
||||
val userKey = requireNotNull(request.key)
|
||||
vaultUnlockResult = unlockVault(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = securityState,
|
||||
signingKey = signingKey,
|
||||
signedPublicKey = signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
privateKey = privateKey,
|
||||
signingKey = signingKey,
|
||||
securityState = securityState,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
|
||||
requestPrivateKey = pendingRequest.requestPrivateKey,
|
||||
method = AuthRequestMethod.UserKey(protectedUserKey = userKey),
|
||||
@@ -2029,10 +2116,13 @@ class AuthRepositoryImpl(
|
||||
}
|
||||
|
||||
vaultUnlockResult = unlockVault(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = securityState,
|
||||
signingKey = signingKey,
|
||||
signedPublicKey = signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
privateKey = privateKey,
|
||||
securityState = securityState,
|
||||
signingKey = signingKey,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.DeviceKey(
|
||||
deviceKey = deviceKey,
|
||||
protectedDevicePrivateKey = encryptedPrivateKey,
|
||||
@@ -2050,20 +2140,16 @@ class AuthRepositoryImpl(
|
||||
* A helper function to unlock the vault for the user associated with the [accountProfile].
|
||||
*/
|
||||
private suspend fun unlockVault(
|
||||
accountCryptographicState: WrappedAccountCryptographicState,
|
||||
accountProfile: AccountJson.Profile,
|
||||
privateKey: String,
|
||||
securityState: String?,
|
||||
signingKey: String?,
|
||||
initUserCryptoMethod: InitUserCryptoMethod,
|
||||
): VaultUnlockResult {
|
||||
val userId = accountProfile.userId
|
||||
return vaultRepository.unlockVault(
|
||||
accountCryptographicState = accountCryptographicState,
|
||||
userId = userId,
|
||||
email = accountProfile.email,
|
||||
kdf = accountProfile.toSdkParams(),
|
||||
privateKey = privateKey,
|
||||
signingKey = signingKey,
|
||||
securityState = securityState,
|
||||
initUserCryptoMethod = initUserCryptoMethod,
|
||||
// The value for the organization keys here will typically be null. We can separately
|
||||
// unlock the vault for organization data after receiving the sync response if this
|
||||
|
||||
@@ -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 {
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.model
|
||||
|
||||
/**
|
||||
* Models result of leaving an organization.
|
||||
*/
|
||||
sealed class RevokeFromOrganizationResult {
|
||||
/**
|
||||
* Revoke from organization succeeded.
|
||||
*/
|
||||
data object Success : RevokeFromOrganizationResult()
|
||||
|
||||
/**
|
||||
* There was an error revoking from the organization.
|
||||
*/
|
||||
data class Error(
|
||||
val error: Throwable?,
|
||||
) : RevokeFromOrganizationResult()
|
||||
}
|
||||
@@ -5,7 +5,11 @@ import android.net.Uri
|
||||
import androidx.browser.auth.AuthTabIntent
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
|
||||
private const val DUO_HOST: String = "duo-callback"
|
||||
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
|
||||
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
|
||||
private const val APP_LINK_SCHEME: String = "https"
|
||||
private const val DEEPLINK_SCHEME: String = "bitwarden"
|
||||
private const val CALLBACK: String = "duo-callback"
|
||||
|
||||
/**
|
||||
* Retrieves a [DuoCallbackTokenResult] from an Intent. There are three possible cases.
|
||||
@@ -18,11 +22,28 @@ private const val DUO_HOST: String = "duo-callback"
|
||||
* - [DuoCallbackTokenResult.Success]: Intent is the Duo callback, and it has a token.
|
||||
*/
|
||||
fun Intent.getDuoCallbackTokenResult(): DuoCallbackTokenResult? {
|
||||
val localData = data
|
||||
return if (action == Intent.ACTION_VIEW && localData != null && localData.host == DUO_HOST) {
|
||||
localData.getDuoCallbackTokenResult()
|
||||
} else {
|
||||
null
|
||||
if (action != Intent.ACTION_VIEW) return null
|
||||
val localData = data ?: return null
|
||||
return when (localData.scheme) {
|
||||
DEEPLINK_SCHEME -> {
|
||||
if (localData.host == CALLBACK) {
|
||||
localData.getDuoCallbackTokenResult()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
APP_LINK_SCHEME -> {
|
||||
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
|
||||
localData.path == "/$CALLBACK"
|
||||
) {
|
||||
localData.getDuoCallbackTokenResult()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,20 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import androidx.browser.auth.AuthTabIntent
|
||||
import androidx.core.net.toUri
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.net.URLEncoder
|
||||
import java.security.MessageDigest
|
||||
import java.util.Base64
|
||||
|
||||
private const val SSO_HOST: String = "sso-callback"
|
||||
const val SSO_URI: String = "bitwarden://$SSO_HOST"
|
||||
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
|
||||
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
|
||||
private const val APP_LINK_SCHEME: String = "https"
|
||||
private const val DEEPLINK_SCHEME: String = "bitwarden"
|
||||
private const val CALLBACK: String = "sso-callback"
|
||||
|
||||
const val SSO_URI: String = "bitwarden://$CALLBACK"
|
||||
|
||||
/**
|
||||
* Generates a URI for the SSO custom tab.
|
||||
@@ -28,7 +34,7 @@ fun generateUriForSso(
|
||||
token: String,
|
||||
state: String,
|
||||
codeVerifier: String,
|
||||
): String {
|
||||
): Uri {
|
||||
val redirectUri = URLEncoder.encode(SSO_URI, "UTF-8")
|
||||
val encodedOrganizationIdentifier = URLEncoder.encode(organizationIdentifier, "UTF-8")
|
||||
val encodedToken = URLEncoder.encode(token, "UTF-8")
|
||||
@@ -39,7 +45,7 @@ fun generateUriForSso(
|
||||
.digest(codeVerifier.toByteArray()),
|
||||
)
|
||||
|
||||
return "$identityBaseUrl/connect/authorize" +
|
||||
val uri = "$identityBaseUrl/connect/authorize" +
|
||||
"?client_id=mobile" +
|
||||
"&redirect_uri=$redirectUri" +
|
||||
"&response_type=code" +
|
||||
@@ -50,6 +56,7 @@ fun generateUriForSso(
|
||||
"&response_mode=query" +
|
||||
"&domain_hint=$encodedOrganizationIdentifier" +
|
||||
"&ssoToken=$encodedToken"
|
||||
return uri.toUri()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,11 +69,28 @@ fun generateUriForSso(
|
||||
* - [SsoCallbackResult.Success]: Intent is the SSO callback with required data.
|
||||
*/
|
||||
fun Intent.getSsoCallbackResult(): SsoCallbackResult? {
|
||||
val localData = data
|
||||
return if (action == Intent.ACTION_VIEW && localData?.host == SSO_HOST) {
|
||||
localData.getSsoCallbackResult()
|
||||
} else {
|
||||
null
|
||||
if (action != Intent.ACTION_VIEW) return null
|
||||
val localData = data ?: return null
|
||||
return when (localData.scheme) {
|
||||
DEEPLINK_SCHEME -> {
|
||||
if (localData.host == CALLBACK) {
|
||||
localData.getSsoCallbackResult()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
APP_LINK_SCHEME -> {
|
||||
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
|
||||
localData.path == "/$CALLBACK"
|
||||
) {
|
||||
localData.getSsoCallbackResult()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,13 @@ import kotlinx.serialization.json.put
|
||||
import java.net.URLEncoder
|
||||
import java.util.Base64
|
||||
|
||||
private const val WEB_AUTH_HOST: String = "webauthn-callback"
|
||||
private const val CALLBACK_URI = "bitwarden://$WEB_AUTH_HOST"
|
||||
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
|
||||
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
|
||||
private const val APP_LINK_SCHEME: String = "https"
|
||||
private const val DEEPLINK_SCHEME: String = "bitwarden"
|
||||
private const val CALLBACK: String = "webauthn-callback"
|
||||
|
||||
private const val CALLBACK_URI = "bitwarden://$CALLBACK"
|
||||
|
||||
/**
|
||||
* Retrieves an [WebAuthResult] from an [Intent]. There are three possible cases.
|
||||
@@ -22,14 +27,28 @@ private const val CALLBACK_URI = "bitwarden://$WEB_AUTH_HOST"
|
||||
* - [WebAuthResult.Failure]: Intent is the web auth key callback with incorrect data.
|
||||
*/
|
||||
fun Intent.getWebAuthResultOrNull(): WebAuthResult? {
|
||||
val localData = data
|
||||
return if (action == Intent.ACTION_VIEW &&
|
||||
localData != null &&
|
||||
localData.host == WEB_AUTH_HOST
|
||||
) {
|
||||
localData.getWebAuthResult()
|
||||
} else {
|
||||
null
|
||||
if (action != Intent.ACTION_VIEW) return null
|
||||
val localData = data ?: return null
|
||||
return when (localData.scheme) {
|
||||
DEEPLINK_SCHEME -> {
|
||||
if (localData.host == CALLBACK) {
|
||||
localData.getWebAuthResult()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
APP_LINK_SCHEME -> {
|
||||
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
|
||||
localData.path == "/$CALLBACK"
|
||||
) {
|
||||
localData.getWebAuthResult()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ class AutofillCipherProviderImpl(
|
||||
it.type is CipherListViewType.Card &&
|
||||
// Must not be deleted.
|
||||
it.deletedDate == null &&
|
||||
// Must not be archived.
|
||||
it.archivedDate == null &&
|
||||
// Must not require a reprompt.
|
||||
it.reprompt == CipherRepromptType.NONE &&
|
||||
// Must not be restricted by organization.
|
||||
@@ -106,6 +108,8 @@ class AutofillCipherProviderImpl(
|
||||
it.type is CipherListViewType.Login &&
|
||||
// Must not be deleted.
|
||||
it.deletedDate == null &&
|
||||
// Must not be archived.
|
||||
it.archivedDate == null &&
|
||||
// Must not require a reprompt.
|
||||
it.reprompt == CipherRepromptType.NONE
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -50,6 +50,8 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import timber.log.Timber
|
||||
|
||||
private const val DAL_ROUTE = ".well-known/assetlinks.json"
|
||||
|
||||
/**
|
||||
* Primary implementation of [BitwardenCredentialManager].
|
||||
*/
|
||||
@@ -123,7 +125,7 @@ class BitwardenCredentialManagerImpl(
|
||||
.getSignatureFingerprintAsHexString()
|
||||
.orEmpty(),
|
||||
host = hostUrl,
|
||||
assetLinkUrl = hostUrl,
|
||||
assetLinkUrl = hostUrl.toDigitalAssetLinkUrl(),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -258,6 +260,7 @@ class BitwardenCredentialManagerImpl(
|
||||
userId = userId,
|
||||
fido2CredentialStore = fido2CredentialStore,
|
||||
relyingPartyId = relyingPartyId,
|
||||
userHandle = null,
|
||||
)
|
||||
.fold(
|
||||
onSuccess = { it },
|
||||
@@ -315,7 +318,7 @@ class BitwardenCredentialManagerImpl(
|
||||
packageName = callingAppInfo.packageName,
|
||||
sha256CertFingerprint = signatureFingerprint,
|
||||
host = host,
|
||||
assetLinkUrl = host,
|
||||
assetLinkUrl = host.toDigitalAssetLinkUrl(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -427,6 +430,13 @@ class BitwardenCredentialManagerImpl(
|
||||
?.relyingParty
|
||||
?.id
|
||||
?.prefixHttpsIfNecessaryOrNull()
|
||||
|
||||
private fun String.toDigitalAssetLinkUrl(): String =
|
||||
when {
|
||||
this.endsWith(DAL_ROUTE) -> this
|
||||
this.endsWith("/") -> "$this$DAL_ROUTE"
|
||||
else -> "$this/$DAL_ROUTE"
|
||||
}
|
||||
}
|
||||
|
||||
private const val MAX_AUTHENTICATION_ATTEMPTS = 5
|
||||
|
||||
@@ -49,6 +49,7 @@ class OriginManagerImpl(
|
||||
)
|
||||
.fold(
|
||||
onSuccess = {
|
||||
Timber.d("Digital asset link validation result: linked = ${it.linked}")
|
||||
if (it.linked) {
|
||||
ValidateOriginResult.Success(null)
|
||||
} else {
|
||||
@@ -56,6 +57,7 @@ class OriginManagerImpl(
|
||||
}
|
||||
},
|
||||
onFailure = {
|
||||
Timber.e("Failed to validate origin for calling app")
|
||||
ValidateOriginResult.Error.AssetLinkNotFound
|
||||
},
|
||||
)
|
||||
@@ -105,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) }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.credentials.provider.PasswordCredentialEntry
|
||||
import androidx.credentials.provider.PublicKeyCredentialEntry
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.core.util.isBuildVersionAtLeast
|
||||
import com.bitwarden.core.util.isHyperOS
|
||||
import javax.crypto.Cipher
|
||||
|
||||
/**
|
||||
@@ -15,7 +16,7 @@ import javax.crypto.Cipher
|
||||
fun PublicKeyCredentialEntry.Builder.setBiometricPromptDataIfSupported(
|
||||
cipher: Cipher?,
|
||||
): PublicKeyCredentialEntry.Builder =
|
||||
if (isBuildVersionAtLeast(Build.VERSION_CODES.VANILLA_ICE_CREAM) && cipher != null) {
|
||||
if (isBiometricPromptDataSupported() && cipher != null) {
|
||||
setBiometricPromptData(
|
||||
biometricPromptData = buildPromptDataWithCipher(cipher),
|
||||
)
|
||||
@@ -29,10 +30,19 @@ fun PublicKeyCredentialEntry.Builder.setBiometricPromptDataIfSupported(
|
||||
fun PasswordCredentialEntry.Builder.setBiometricPromptDataIfSupported(
|
||||
cipher: Cipher?,
|
||||
): PasswordCredentialEntry.Builder =
|
||||
if (isBuildVersionAtLeast(Build.VERSION_CODES.VANILLA_ICE_CREAM) && cipher != null) {
|
||||
if (isBiometricPromptDataSupported() && cipher != null) {
|
||||
setBiometricPromptData(
|
||||
biometricPromptData = buildPromptDataWithCipher(cipher),
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether biometric prompt data is supported on this device.
|
||||
* Note: Xiaomi HyperOS is known to be incompatible.
|
||||
*/
|
||||
private fun isBiometricPromptDataSupported(): Boolean {
|
||||
return isBuildVersionAtLeast(Build.VERSION_CODES.VANILLA_ICE_CREAM) &&
|
||||
!isHyperOS()
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ class EventDiskSourceImpl(
|
||||
},
|
||||
cipherId = event.cipherId,
|
||||
date = event.date,
|
||||
organizationId = event.organizationId,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -48,6 +49,7 @@ class EventDiskSourceImpl(
|
||||
},
|
||||
cipherId = it.cipherId,
|
||||
date = it.date,
|
||||
organizationId = it.organizationId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.disk
|
||||
|
||||
import com.bitwarden.data.datasource.disk.FlightRecorderDiskSource
|
||||
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
|
||||
@@ -13,7 +13,7 @@ import java.time.Instant
|
||||
* Primary access point for general settings-related disk information.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
interface SettingsDiskSource {
|
||||
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.
|
||||
*/
|
||||
@@ -115,6 +105,24 @@ interface SettingsDiskSource {
|
||||
*/
|
||||
fun clearData(userId: String)
|
||||
|
||||
/**
|
||||
* Retrieves the stored value of whether the introducing archive action card has been dismissed.
|
||||
*/
|
||||
fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean?
|
||||
|
||||
/**
|
||||
* Stores whether the introducing archive action card has been dismissed.
|
||||
*/
|
||||
fun storeIntroducingArchiveActionCardDismissed(
|
||||
userId: String,
|
||||
isDismissed: Boolean?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Emits updates that track [getIntroducingArchiveActionCardDismissed] for the given [userId].
|
||||
*/
|
||||
fun getIntroducingArchiveActionCardDismissedFlow(userId: String): Flow<Boolean?>
|
||||
|
||||
/**
|
||||
* Retrieves the biometric integrity validity for the given [userId] and
|
||||
* [systemBioIntegrityState].
|
||||
|
||||
@@ -5,8 +5,8 @@ import androidx.core.content.edit
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.core.data.util.decodeFromStringOrNull
|
||||
import com.bitwarden.data.datasource.disk.BaseDiskSource
|
||||
import com.bitwarden.data.datasource.disk.FlightRecorderDiskSource
|
||||
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
|
||||
@@ -47,9 +47,10 @@ private const val CREATE_ACTION_COUNT = "createActionCount"
|
||||
private const val SHOULD_SHOW_ADD_LOGIN_COACH_MARK = "shouldShowAddLoginCoachMark"
|
||||
private const val SHOULD_SHOW_GENERATOR_COACH_MARK = "shouldShowGeneratorCoachMark"
|
||||
private const val RESUME_SCREEN = "resumeScreen"
|
||||
private const val FLIGHT_RECORDER_KEY = "flightRecorderData"
|
||||
private const val IS_DYNAMIC_COLORS_ENABLED = "isDynamicColorsEnabled"
|
||||
private const val BROWSER_AUTOFILL_DIALOG_RESHOW_TIME = "browserAutofillDialogReshowTime"
|
||||
private const val INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED =
|
||||
"introducingArchiveActionCardDismissed"
|
||||
|
||||
/**
|
||||
* Primary implementation of [SettingsDiskSource].
|
||||
@@ -58,8 +59,10 @@ private const val BROWSER_AUTOFILL_DIALOG_RESHOW_TIME = "browserAutofillDialogRe
|
||||
class SettingsDiskSourceImpl(
|
||||
private val sharedPreferences: SharedPreferences,
|
||||
private val json: Json,
|
||||
flightRecorderDiskSource: FlightRecorderDiskSource,
|
||||
) : BaseDiskSource(sharedPreferences = sharedPreferences),
|
||||
SettingsDiskSource {
|
||||
SettingsDiskSource,
|
||||
FlightRecorderDiskSource by flightRecorderDiskSource {
|
||||
private val mutableAppLanguageFlow = bufferedMutableSharedFlow<AppLanguage?>(replay = 1)
|
||||
private val mutableAppThemeFlow = bufferedMutableSharedFlow<AppTheme>(replay = 1)
|
||||
|
||||
@@ -86,14 +89,15 @@ class SettingsDiskSourceImpl(
|
||||
private val mutableShowImportLoginsSettingBadgeFlowMap =
|
||||
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
|
||||
|
||||
private val mutableIntroducingArchiveActionCardDismissedFlowMap =
|
||||
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
|
||||
|
||||
private val mutableIsIconLoadingDisabledFlow = bufferedMutableSharedFlow<Boolean?>()
|
||||
|
||||
private val mutableIsCrashLoggingEnabledFlow = bufferedMutableSharedFlow<Boolean?>()
|
||||
|
||||
private val mutableHasUserLoggedInOrCreatedAccountFlow = bufferedMutableSharedFlow<Boolean?>()
|
||||
|
||||
private val mutableFlightRecorderDataFlow = bufferedMutableSharedFlow<FlightRecorderDataSet?>()
|
||||
|
||||
private val mutableHasSeenAddLoginCoachMarkFlow = bufferedMutableSharedFlow<Boolean?>()
|
||||
|
||||
private val mutableHasSeenGeneratorCoachMarkFlow = bufferedMutableSharedFlow<Boolean?>()
|
||||
@@ -214,20 +218,6 @@ class SettingsDiskSourceImpl(
|
||||
get() = mutableHasUserLoggedInOrCreatedAccountFlow
|
||||
.onSubscription { emit(getBoolean(HAS_USER_LOGGED_IN_OR_CREATED_AN_ACCOUNT_KEY)) }
|
||||
|
||||
override var flightRecorderData: FlightRecorderDataSet?
|
||||
get() = getString(key = FLIGHT_RECORDER_KEY)
|
||||
?.let { json.decodeFromStringOrNull<FlightRecorderDataSet>(it) }
|
||||
set(value) {
|
||||
putString(
|
||||
key = FLIGHT_RECORDER_KEY,
|
||||
value = value?.let { json.encodeToString(it) },
|
||||
)
|
||||
mutableFlightRecorderDataFlow.tryEmit(value)
|
||||
}
|
||||
|
||||
override val flightRecorderDataFlow: Flow<FlightRecorderDataSet?>
|
||||
get() = mutableFlightRecorderDataFlow.onSubscription { emit(flightRecorderData) }
|
||||
|
||||
override var browserAutofillDialogReshowTime: Instant?
|
||||
get() = getLong(key = BROWSER_AUTOFILL_DIALOG_RESHOW_TIME)?.let { Instant.ofEpochMilli(it) }
|
||||
set(value) {
|
||||
@@ -255,8 +245,29 @@ class SettingsDiskSourceImpl(
|
||||
// - show unlock setting badge
|
||||
// - should show add login coach mark
|
||||
// - should show generator coach mark
|
||||
// - should show introducing archive action card dismissed
|
||||
}
|
||||
|
||||
override fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean? =
|
||||
getBoolean(
|
||||
key = INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED.appendIdentifier(identifier = userId),
|
||||
)
|
||||
|
||||
override fun storeIntroducingArchiveActionCardDismissed(
|
||||
userId: String,
|
||||
isDismissed: Boolean?,
|
||||
) {
|
||||
putBoolean(
|
||||
key = INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED.appendIdentifier(identifier = userId),
|
||||
value = isDismissed,
|
||||
)
|
||||
getMutableIntroducingArchiveActionCardDismissedFlow(userId = userId).tryEmit(isDismissed)
|
||||
}
|
||||
|
||||
override fun getIntroducingArchiveActionCardDismissedFlow(userId: String): Flow<Boolean?> =
|
||||
getMutableIntroducingArchiveActionCardDismissedFlow(userId = userId)
|
||||
.onSubscription { emit(getIntroducingArchiveActionCardDismissed(userId = userId)) }
|
||||
|
||||
override fun getAccountBiometricIntegrityValidity(
|
||||
userId: String,
|
||||
systemBioIntegrityState: String,
|
||||
@@ -594,6 +605,13 @@ class SettingsDiskSourceImpl(
|
||||
override fun getAppResumeScreen(userId: String): AppResumeScreenData? =
|
||||
getString(RESUME_SCREEN.appendIdentifier(userId))?.let { json.decodeFromStringOrNull(it) }
|
||||
|
||||
private fun getMutableIntroducingArchiveActionCardDismissedFlow(
|
||||
userId: String,
|
||||
): MutableSharedFlow<Boolean?> =
|
||||
mutableIntroducingArchiveActionCardDismissedFlowMap.getOrPut(userId) {
|
||||
bufferedMutableSharedFlow(replay = 1)
|
||||
}
|
||||
|
||||
private fun getMutableLastSyncFlow(
|
||||
userId: String,
|
||||
): MutableSharedFlow<Instant?> =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.disk.database
|
||||
|
||||
import androidx.room.AutoMigration
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
@@ -14,8 +15,11 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTyp
|
||||
entities = [
|
||||
OrganizationEventEntity::class,
|
||||
],
|
||||
version = 1,
|
||||
version = 2,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 1, to = 2),
|
||||
],
|
||||
)
|
||||
@TypeConverters(ZonedDateTimeTypeConverter::class)
|
||||
abstract class PlatformDatabase : RoomDatabase() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.room.Room
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.data.datasource.disk.FlightRecorderDiskSource
|
||||
import com.bitwarden.data.datasource.disk.di.EncryptedPreferences
|
||||
import com.bitwarden.data.datasource.disk.di.UnencryptedPreferences
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
|
||||
@@ -139,10 +140,12 @@ object PlatformDiskModule {
|
||||
fun provideSettingsDiskSource(
|
||||
@UnencryptedPreferences sharedPreferences: SharedPreferences,
|
||||
json: Json,
|
||||
flightRecorderDiskSource: FlightRecorderDiskSource,
|
||||
): SettingsDiskSource =
|
||||
SettingsDiskSourceImpl(
|
||||
sharedPreferences = sharedPreferences,
|
||||
json = json,
|
||||
flightRecorderDiskSource = flightRecorderDiskSource,
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -25,4 +25,7 @@ data class OrganizationEventEntity(
|
||||
|
||||
@ColumnInfo(name = "date")
|
||||
val date: ZonedDateTime,
|
||||
|
||||
@ColumnInfo(name = "organization_id")
|
||||
val organizationId: String?,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,10 +63,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 +80,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,34 +104,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(
|
||||
@ApplicationContext context: Context,
|
||||
clock: Clock,
|
||||
dispatcherManager: DispatcherManager,
|
||||
settingsDiskSource: SettingsDiskSource,
|
||||
flightRecorderWriter: FlightRecorderWriter,
|
||||
): FlightRecorderManager = FlightRecorderManagerImpl(
|
||||
context = context,
|
||||
clock = clock,
|
||||
dispatcherManager = dispatcherManager,
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
flightRecorderWriter = flightRecorderWriter,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthenticatorBridgeProcessor(
|
||||
|
||||
@@ -79,6 +79,7 @@ class OrganizationEventManagerImpl(
|
||||
type = event.type,
|
||||
cipherId = event.cipherId,
|
||||
date = ZonedDateTime.now(clock),
|
||||
organizationId = event.organizationId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,11 +16,17 @@ sealed class OrganizationEvent {
|
||||
*/
|
||||
abstract val cipherId: String?
|
||||
|
||||
/**
|
||||
* The optional organization ID.
|
||||
*/
|
||||
abstract val organizationId: String?
|
||||
|
||||
/**
|
||||
* Tracks when a value is successfully auto-filled
|
||||
*/
|
||||
data class CipherClientAutoFilled(
|
||||
override val cipherId: String,
|
||||
override val organizationId: String? = null,
|
||||
) : OrganizationEvent() {
|
||||
override val type: OrganizationEventType
|
||||
get() = OrganizationEventType.CIPHER_CLIENT_AUTO_FILLED
|
||||
@@ -31,6 +37,7 @@ sealed class OrganizationEvent {
|
||||
*/
|
||||
data class CipherClientCopiedCardCode(
|
||||
override val cipherId: String,
|
||||
override val organizationId: String? = null,
|
||||
) : OrganizationEvent() {
|
||||
override val type: OrganizationEventType
|
||||
get() = OrganizationEventType.CIPHER_CLIENT_COPIED_CARD_CODE
|
||||
@@ -41,6 +48,7 @@ sealed class OrganizationEvent {
|
||||
*/
|
||||
data class CipherClientCopiedHiddenField(
|
||||
override val cipherId: String,
|
||||
override val organizationId: String? = null,
|
||||
) : OrganizationEvent() {
|
||||
override val type: OrganizationEventType
|
||||
get() = OrganizationEventType.CIPHER_CLIENT_COPIED_HIDDEN_FIELD
|
||||
@@ -51,6 +59,7 @@ sealed class OrganizationEvent {
|
||||
*/
|
||||
data class CipherClientCopiedPassword(
|
||||
override val cipherId: String,
|
||||
override val organizationId: String? = null,
|
||||
) : OrganizationEvent() {
|
||||
override val type: OrganizationEventType
|
||||
get() = OrganizationEventType.CIPHER_CLIENT_COPIED_PASSWORD
|
||||
@@ -61,6 +70,7 @@ sealed class OrganizationEvent {
|
||||
*/
|
||||
data class CipherClientToggledCardCodeVisible(
|
||||
override val cipherId: String,
|
||||
override val organizationId: String? = null,
|
||||
) : OrganizationEvent() {
|
||||
override val type: OrganizationEventType
|
||||
get() = OrganizationEventType.CIPHER_CLIENT_TOGGLED_CARD_CODE_VISIBLE
|
||||
@@ -71,6 +81,7 @@ sealed class OrganizationEvent {
|
||||
*/
|
||||
data class CipherClientToggledCardNumberVisible(
|
||||
override val cipherId: String,
|
||||
override val organizationId: String? = null,
|
||||
) : OrganizationEvent() {
|
||||
override val type: OrganizationEventType
|
||||
get() = OrganizationEventType.CIPHER_CLIENT_TOGGLED_CARD_NUMBER_VISIBLE
|
||||
@@ -81,6 +92,7 @@ sealed class OrganizationEvent {
|
||||
*/
|
||||
data class CipherClientToggledHiddenFieldVisible(
|
||||
override val cipherId: String,
|
||||
override val organizationId: String? = null,
|
||||
) : OrganizationEvent() {
|
||||
override val type: OrganizationEventType
|
||||
get() = OrganizationEventType.CIPHER_CLIENT_TOGGLED_HIDDEN_FIELD_VISIBLE
|
||||
@@ -91,6 +103,7 @@ sealed class OrganizationEvent {
|
||||
*/
|
||||
data class CipherClientToggledPasswordVisible(
|
||||
override val cipherId: String,
|
||||
override val organizationId: String? = null,
|
||||
) : OrganizationEvent() {
|
||||
override val type: OrganizationEventType
|
||||
get() = OrganizationEventType.CIPHER_CLIENT_TOGGLED_PASSWORD_VISIBLE
|
||||
@@ -101,6 +114,7 @@ sealed class OrganizationEvent {
|
||||
*/
|
||||
data class CipherClientViewed(
|
||||
override val cipherId: String,
|
||||
override val organizationId: String? = null,
|
||||
) : OrganizationEvent() {
|
||||
override val type: OrganizationEventType
|
||||
get() = OrganizationEventType.CIPHER_CLIENT_VIEWED
|
||||
@@ -111,7 +125,32 @@ sealed class OrganizationEvent {
|
||||
*/
|
||||
data object UserClientExportedVault : OrganizationEvent() {
|
||||
override val cipherId: String? = null
|
||||
override val organizationId: String? = null
|
||||
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 class ItemOrganizationAccepted(
|
||||
override val cipherId: String? = null,
|
||||
override val organizationId: String,
|
||||
) : OrganizationEvent() {
|
||||
override val type: OrganizationEventType
|
||||
get() = OrganizationEventType.ORGANIZATION_ITEM_ORGANIZATION_ACCEPTED
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks when a user chooses to leave an organization instead of migrating their personal
|
||||
* ciphers to their organization's My Items folder.
|
||||
*/
|
||||
data class ItemOrganizationDeclined(
|
||||
override val cipherId: String? = null,
|
||||
override val organizationId: String,
|
||||
) : OrganizationEvent() {
|
||||
override val type: OrganizationEventType
|
||||
get() = OrganizationEventType.ORGANIZATION_ITEM_ORGANIZATION_DECLINED
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,18 +4,19 @@ 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
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.createWrappedAccountCryptographicState
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.toVaultUnlockResult
|
||||
|
||||
@@ -137,17 +138,21 @@ class AuthenticatorBridgeRepositoryImpl(
|
||||
?.securityState
|
||||
?.securityState
|
||||
val signingKey = accountKeys?.signatureKeyPair?.wrappedSigningKey
|
||||
val signedPublicKey = accountKeys?.publicKeyEncryptionKeyPair?.signedPublicKey
|
||||
|
||||
return scopedVaultSdkSource
|
||||
.initializeCrypto(
|
||||
userId = userId,
|
||||
request = InitUserCryptoRequest(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = securityState,
|
||||
signingKey = signingKey,
|
||||
signedPublicKey = signedPublicKey,
|
||||
),
|
||||
userId = userId,
|
||||
kdfParams = account.profile.toSdkParams(),
|
||||
email = account.profile.email,
|
||||
privateKey = privateKey,
|
||||
securityState = securityState,
|
||||
signingKey = signingKey,
|
||||
method = InitUserCryptoMethod.DecryptedKey(
|
||||
decryptedUserKey = decryptedUserKey,
|
||||
),
|
||||
|
||||
@@ -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
|
||||
@@ -242,6 +242,16 @@ interface SettingsRepository : FlightRecorderManager {
|
||||
*/
|
||||
fun storePullToRefreshEnabled(isPullToRefreshEnabled: Boolean)
|
||||
|
||||
/**
|
||||
* Gets updates for whether the introducing archive action card is dismissed.
|
||||
*/
|
||||
fun getIntroducingArchiveActionCardDismissedFlow(): StateFlow<Boolean>
|
||||
|
||||
/**
|
||||
* Stores that the introducing archive action card has been dismissed for the active user.
|
||||
*/
|
||||
fun dismissIntroducingArchiveActionCard()
|
||||
|
||||
/**
|
||||
* Stores the encrypted user key for biometrics, allowing it to be used to unlock the current
|
||||
* user's vault.
|
||||
|
||||
@@ -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
|
||||
@@ -500,6 +500,29 @@ class SettingsRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override fun getIntroducingArchiveActionCardDismissedFlow(): StateFlow<Boolean> {
|
||||
val userId = activeUserId ?: return MutableStateFlow(value = false)
|
||||
return settingsDiskSource
|
||||
.getIntroducingArchiveActionCardDismissedFlow(userId = userId)
|
||||
.map { it ?: false }
|
||||
.stateIn(
|
||||
scope = unconfinedScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = settingsDiskSource
|
||||
.getIntroducingArchiveActionCardDismissed(userId = userId)
|
||||
?: false,
|
||||
)
|
||||
}
|
||||
|
||||
override fun dismissIntroducingArchiveActionCard() {
|
||||
activeUserId?.let {
|
||||
settingsDiskSource.storeIntroducingArchiveActionCardDismissed(
|
||||
userId = it,
|
||||
isDismissed = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setupBiometricsKey(cipher: Cipher): BiometricsKeyResult {
|
||||
val userId = activeUserId
|
||||
?: return BiometricsKeyResult.Error(error = NoActiveUserException())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,10 +14,36 @@ import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Invokes the [observer] callback whenever the user is logged in, the active changes, and there
|
||||
* are subscribers to the [MutableStateFlow]. The flow from all previous calls to the `observer`
|
||||
* is canceled whenever the `observer` is re-invoked, there is no active user (logged-out), or
|
||||
* there are no subscribers to the [MutableStateFlow].
|
||||
* Lazily invokes the [observer] callback with the active user's ID only when this MutableStateFlow
|
||||
* has external collectors and a user is logged in. Designed for operations that should only run
|
||||
* when UI actively observes the resulting data, but do not require the vault to be unlocked.
|
||||
*
|
||||
* **Active User Tracking:**
|
||||
* This function specifically tracks the active user from [userStateFlow]. When the active user
|
||||
* changes (e.g., account switching), the previous observer flow is canceled and a new one is
|
||||
* started for the new active user.
|
||||
*
|
||||
* **Subscription Detection:**
|
||||
* Uses [MutableStateFlow.subscriptionCount] to detect external collectors. Only external
|
||||
* `.collect()` calls increment subscriptionCount—internal flow operations (map, flatMapLatest,
|
||||
* update, etc.) do not affect it.
|
||||
*
|
||||
* **Common Pattern:**
|
||||
* ```kotlin
|
||||
* private val _triggerFlow = MutableStateFlow(Unit)
|
||||
* val dataFlow = _triggerFlow
|
||||
* .observeWhenSubscribedAndLoggedIn(userFlow) { activeUserId ->
|
||||
* repository.getData(activeUserId) // Only runs when dataFlow is collected
|
||||
* }
|
||||
* // _triggerFlow.update {} does NOT affect subscriptionCount
|
||||
* ```
|
||||
*
|
||||
* **Observer Lifecycle:**
|
||||
* - **Invoked** when subscriptionCount > 0 and a user is logged in
|
||||
* - **Re-invoked** when the active user changes (account switch)
|
||||
* - **Canceled** when subscribers disconnect or user logs out
|
||||
*
|
||||
* @see observeWhenSubscribedAndUnlocked for variant that also requires vault to be unlocked
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun <T, R> MutableStateFlow<T>.observeWhenSubscribedAndLoggedIn(
|
||||
@@ -35,11 +61,36 @@ fun <T, R> MutableStateFlow<T>.observeWhenSubscribedAndLoggedIn(
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes the [observer] callback whenever the user is logged in, the active changes,
|
||||
* the vault for the user changes and there are subscribers to the [MutableStateFlow].
|
||||
* The flow from all previous calls to the `observer`
|
||||
* is canceled whenever the `observer` is re-invoked, there is no active user (logged-out),
|
||||
* there are no subscribers to the [MutableStateFlow] or the vault is not unlocked.
|
||||
* Lazily invokes the [observer] callback with the active user's ID only when this MutableStateFlow
|
||||
* has external collectors, a user is logged in, and the active user's vault is unlocked. Designed
|
||||
* for expensive operations that should only run when UI actively observes the resulting data.
|
||||
*
|
||||
* **Active User Tracking:**
|
||||
* This function specifically tracks the active user from [userStateFlow]. When the active user
|
||||
* changes (e.g., account switching), the previous observer flow is canceled and a new one is
|
||||
* started for the new active user. The vault unlock state is also tracked per-user.
|
||||
*
|
||||
* **Subscription Detection:**
|
||||
* Uses [MutableStateFlow.subscriptionCount] to detect external collectors. Only external
|
||||
* `.collect()` calls increment subscriptionCount—internal flow operations (map, flatMapLatest,
|
||||
* update, etc.) do not affect it.
|
||||
*
|
||||
* **Common Pattern:**
|
||||
* ```kotlin
|
||||
* private val _triggerFlow = MutableStateFlow(Unit)
|
||||
* val dataFlow = _triggerFlow
|
||||
* .observeWhenSubscribedAndUnlocked(userFlow, unlockFlow) { activeUserId ->
|
||||
* repository.getExpensiveData(activeUserId) // Only runs when dataFlow is collected
|
||||
* }
|
||||
* // _triggerFlow.update {} does NOT affect subscriptionCount
|
||||
* ```
|
||||
*
|
||||
* **Observer Lifecycle:**
|
||||
* - **Invoked** when subscriptionCount > 0, a user is logged in, and active user's vault unlocked
|
||||
* - **Re-invoked** when the active user changes (account switch) or vault state changes
|
||||
* - **Canceled** when subscribers disconnect, user logs out, or vault locks
|
||||
*
|
||||
* @see observeWhenSubscribedAndLoggedIn for variant without vault unlock requirement
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun <T, R> MutableStateFlow<T>.observeWhenSubscribedAndUnlocked(
|
||||
|
||||
@@ -24,6 +24,16 @@ interface VaultDiskSource {
|
||||
*/
|
||||
suspend fun getCiphers(userId: String): List<SyncResponseJson.Cipher>
|
||||
|
||||
/**
|
||||
* Checks if the user has any personal ciphers (ciphers not belonging to an organization).
|
||||
*
|
||||
* This is an optimized query that checks only the indexed organizationId column
|
||||
* without loading full cipher JSON data. Intended for vault migration state checks.
|
||||
*
|
||||
* @return Flow that emits true if user has personal ciphers, false otherwise
|
||||
*/
|
||||
fun hasPersonalCiphersFlow(userId: String): Flow<Boolean>
|
||||
|
||||
/**
|
||||
* Retrieves all ciphers with the given [cipherIds] from the data source for a given [userId].
|
||||
*/
|
||||
|
||||
@@ -55,6 +55,7 @@ class VaultDiskSourceImpl(
|
||||
hasTotp = cipher.login?.totp != null,
|
||||
cipherType = json.encodeToString(cipher.type),
|
||||
cipherJson = json.encodeToString(cipher),
|
||||
organizationId = cipher.organizationId,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -97,6 +98,9 @@ class VaultDiskSourceImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override fun hasPersonalCiphersFlow(userId: String): Flow<Boolean> =
|
||||
ciphersDao.hasPersonalCiphersFlow(userId = userId)
|
||||
|
||||
override suspend fun getSelectedCiphers(
|
||||
userId: String,
|
||||
cipherIds: List<String>,
|
||||
@@ -295,6 +299,7 @@ class VaultDiskSourceImpl(
|
||||
hasTotp = cipher.login?.totp != null,
|
||||
cipherType = json.encodeToString(cipher.type),
|
||||
cipherJson = json.encodeToString(cipher),
|
||||
organizationId = cipher.organizationId,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -88,4 +88,21 @@ interface CiphersDao {
|
||||
insertCiphers(ciphers)
|
||||
return deletedCiphersCount > 0 || ciphers.isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user has any personal ciphers (ciphers with null organizationId).
|
||||
* Returns a Flow that emits true if personal ciphers exist, false otherwise.
|
||||
*
|
||||
* This query is optimized for vault migration checks and uses the indexed
|
||||
* organization_id column to avoid loading full cipher JSON.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM ciphers
|
||||
WHERE user_id = :userId
|
||||
AND organization_id IS NULL
|
||||
LIMIT 1
|
||||
)
|
||||
""")
|
||||
fun hasPersonalCiphersFlow(userId: String): Flow<Boolean>
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.entity.SendEntity
|
||||
FolderEntity::class,
|
||||
SendEntity::class,
|
||||
],
|
||||
version = 8,
|
||||
version = 9,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 6, to = 7),
|
||||
|
||||
@@ -2,18 +2,25 @@ package com.x8bit.bitwarden.data.vault.datasource.disk.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Entity representing a cipher in the database.
|
||||
*/
|
||||
@Entity(tableName = "ciphers")
|
||||
@Entity(
|
||||
tableName = "ciphers",
|
||||
indices = [
|
||||
Index(value = ["user_id"]),
|
||||
Index(value = ["user_id", "organization_id"]),
|
||||
],
|
||||
)
|
||||
data class CipherEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@ColumnInfo(name = "id")
|
||||
val id: String,
|
||||
|
||||
@ColumnInfo(name = "user_id", index = true)
|
||||
@ColumnInfo(name = "user_id")
|
||||
val userId: String,
|
||||
|
||||
// Default to true for initial migration.
|
||||
@@ -26,4 +33,9 @@ data class CipherEntity(
|
||||
|
||||
@ColumnInfo(name = "cipher_json")
|
||||
val cipherJson: String,
|
||||
|
||||
// Extracted organizationId for query optimization to avoid loading full cipher JSON.
|
||||
// Enables lightweight queries for vault migration checks and organization filtering.
|
||||
@ColumnInfo(name = "organization_id")
|
||||
val organizationId: String?,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.vault.datasource.sdk
|
||||
|
||||
import com.bitwarden.collections.Collection
|
||||
import com.bitwarden.collections.CollectionId
|
||||
import com.bitwarden.collections.CollectionView
|
||||
import com.bitwarden.core.EnrollPinResponse
|
||||
import com.bitwarden.core.InitOrgCryptoRequest
|
||||
@@ -97,12 +98,13 @@ interface VaultSdkSource {
|
||||
): Result<EnrollPinResponse>
|
||||
|
||||
/**
|
||||
* Validate the user pin using the [pinProtectedUserKey].
|
||||
* Validates that the given PIN with the encrypted user key and returns `true` if the PIN is
|
||||
* correct, otherwise `false`.
|
||||
*/
|
||||
suspend fun validatePin(
|
||||
suspend fun validatePinUserKey(
|
||||
userId: String,
|
||||
pin: String,
|
||||
pinProtectedUserKey: String,
|
||||
pinProtectedUserKeyEnvelope: String,
|
||||
): Result<Boolean>
|
||||
|
||||
/**
|
||||
@@ -388,6 +390,16 @@ interface VaultSdkSource {
|
||||
cipherView: CipherView,
|
||||
): Result<CipherView>
|
||||
|
||||
/**
|
||||
* Re-encrypts the [cipherViews] with the organizations encryption key into the respective [collectionIds]
|
||||
*/
|
||||
suspend fun bulkMoveToOrganization(
|
||||
userId: String,
|
||||
organizationId: String,
|
||||
cipherViews: List<CipherView>,
|
||||
collectionIds: List<CollectionId>,
|
||||
): Result<List<EncryptionContext>>
|
||||
|
||||
/**
|
||||
* Validates that the given password matches the password hash.
|
||||
*/
|
||||
@@ -487,6 +499,7 @@ interface VaultSdkSource {
|
||||
userId: String,
|
||||
fido2CredentialStore: Fido2CredentialStore,
|
||||
relyingPartyId: String,
|
||||
userHandle: String?,
|
||||
): Result<List<Fido2CredentialAutofillView>>
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.vault.datasource.sdk
|
||||
|
||||
import com.bitwarden.collections.Collection
|
||||
import com.bitwarden.collections.CollectionId
|
||||
import com.bitwarden.collections.CollectionView
|
||||
import com.bitwarden.core.DeriveKeyConnectorException
|
||||
import com.bitwarden.core.DeriveKeyConnectorRequest
|
||||
@@ -100,6 +101,7 @@ class VaultSdkSourceImpl(
|
||||
is DeriveKeyConnectorException.WrongPassword -> {
|
||||
DeriveKeyConnectorResult.WrongPasswordError
|
||||
}
|
||||
|
||||
is DeriveKeyConnectorException.Crypto -> {
|
||||
DeriveKeyConnectorResult.Error(error = ex)
|
||||
}
|
||||
@@ -129,15 +131,18 @@ class VaultSdkSourceImpl(
|
||||
.enrollPinWithEncryptedPin(encryptedPin = encryptedPin)
|
||||
}
|
||||
|
||||
override suspend fun validatePin(
|
||||
override suspend fun validatePinUserKey(
|
||||
userId: String,
|
||||
pin: String,
|
||||
pinProtectedUserKey: String,
|
||||
pinProtectedUserKeyEnvelope: String,
|
||||
): Result<Boolean> =
|
||||
runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.auth()
|
||||
.validatePin(pin = pin, pinProtectedUserKey = pinProtectedUserKey)
|
||||
.validatePinProtectedUserKeyEnvelope(
|
||||
pin = pin,
|
||||
pinProtectedUserKeyEnvelope = pinProtectedUserKeyEnvelope,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getAuthRequestKey(
|
||||
@@ -447,6 +452,22 @@ class VaultSdkSourceImpl(
|
||||
.moveToOrganization(cipher = cipherView, organizationId = organizationId)
|
||||
}
|
||||
|
||||
override suspend fun bulkMoveToOrganization(
|
||||
userId: String,
|
||||
organizationId: String,
|
||||
cipherViews: List<CipherView>,
|
||||
collectionIds: List<CollectionId>,
|
||||
): Result<List<EncryptionContext>> = runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.vault()
|
||||
.ciphers()
|
||||
.prepareCiphersForBulkShare(
|
||||
organizationId = organizationId,
|
||||
ciphers = cipherViews,
|
||||
collectionIds = collectionIds,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun validatePassword(
|
||||
userId: String,
|
||||
password: String,
|
||||
@@ -599,6 +620,7 @@ class VaultSdkSourceImpl(
|
||||
userId: String,
|
||||
fido2CredentialStore: Fido2CredentialStore,
|
||||
relyingPartyId: String,
|
||||
userHandle: String?,
|
||||
): Result<List<Fido2CredentialAutofillView>> = runCatchingWithLogs {
|
||||
getClient(userId)
|
||||
.platform()
|
||||
@@ -607,7 +629,7 @@ class VaultSdkSourceImpl(
|
||||
userInterface = Fido2CredentialSearchUserInterfaceImpl(),
|
||||
credentialStore = fido2CredentialStore,
|
||||
)
|
||||
.silentlyDiscoverCredentials(relyingPartyId)
|
||||
.silentlyDiscoverCredentials(relyingPartyId, userHandle?.toByteArray())
|
||||
}
|
||||
|
||||
override suspend fun makeUpdateKdf(
|
||||
|
||||
@@ -28,7 +28,7 @@ class Fido2CredentialAuthenticationUserInterfaceImpl(
|
||||
newCredential: Fido2CredentialNewView,
|
||||
): CheckUserAndPickCredentialForCreationResult = throw IllegalStateException()
|
||||
|
||||
override suspend fun isVerificationEnabled(): Boolean = isVerificationSupported
|
||||
override fun isVerificationEnabled(): Boolean = isVerificationSupported
|
||||
|
||||
override suspend fun pickCredentialForAuthentication(
|
||||
availableCredentials: List<CipherView>,
|
||||
|
||||
@@ -32,7 +32,7 @@ class Fido2CredentialRegistrationUserInterfaceImpl(
|
||||
checkUserResult = CheckUserResult(userPresent = true, userVerified = true),
|
||||
)
|
||||
|
||||
override suspend fun isVerificationEnabled(): Boolean = isVerificationSupported
|
||||
override fun isVerificationEnabled(): Boolean = isVerificationSupported
|
||||
|
||||
override suspend fun pickCredentialForAuthentication(
|
||||
availableCredentials: List<CipherView>,
|
||||
|
||||
@@ -29,7 +29,7 @@ class Fido2CredentialSearchUserInterfaceImpl : Fido2UserInterface {
|
||||
|
||||
// Always return true for this property because any problems with verification should
|
||||
// be handled downstream where the app can actually offer verification methods.
|
||||
override suspend fun isVerificationEnabled(): Boolean = true
|
||||
override fun isVerificationEnabled(): Boolean = true
|
||||
|
||||
override suspend fun pickCredentialForAuthentication(
|
||||
availableCredentials: List<CipherView>,
|
||||
|
||||
@@ -42,7 +42,11 @@ class Fido2CredentialStoreImpl(
|
||||
* @param ids Optional list of FIDO 2 credential ID's to find.
|
||||
* @param ripId Relying Party ID to find.
|
||||
*/
|
||||
override suspend fun findCredentials(ids: List<ByteArray>?, ripId: String): List<CipherView> =
|
||||
override suspend fun findCredentials(
|
||||
ids: List<ByteArray>?,
|
||||
ripId: String,
|
||||
userHandle: ByteArray?,
|
||||
): List<CipherView> =
|
||||
vaultRepository
|
||||
.decryptCipherListResultStateFlow
|
||||
.value
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.vault.manager
|
||||
import android.net.Uri
|
||||
import com.bitwarden.vault.CipherView
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.CreateAttachmentResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteAttachmentResult
|
||||
@@ -10,6 +11,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
|
||||
|
||||
/**
|
||||
@@ -57,6 +59,22 @@ interface CipherManager {
|
||||
*/
|
||||
suspend fun getCipher(cipherId: String): GetCipherResult
|
||||
|
||||
/**
|
||||
* Attempt to archive a cipher.
|
||||
*/
|
||||
suspend fun archiveCipher(
|
||||
cipherId: String,
|
||||
cipherView: CipherView,
|
||||
): ArchiveCipherResult
|
||||
|
||||
/**
|
||||
* Attempt to unarchive a cipher.
|
||||
*/
|
||||
suspend fun unarchiveCipher(
|
||||
cipherId: String,
|
||||
cipherView: CipherView,
|
||||
): UnarchiveCipherResult
|
||||
|
||||
/**
|
||||
* Attempt to delete a cipher.
|
||||
*/
|
||||
@@ -115,4 +133,12 @@ interface CipherManager {
|
||||
cipherView: CipherView,
|
||||
collectionIds: List<String>,
|
||||
): ShareCipherResult
|
||||
|
||||
/**
|
||||
* Migrate the attachments if they don't have their own key
|
||||
*/
|
||||
suspend fun migrateAttachments(
|
||||
userId: String,
|
||||
cipherView: CipherView,
|
||||
): Result<CipherView>
|
||||
}
|
||||
|
||||
@@ -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,8 +27,8 @@ 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.ArchiveCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.CreateAttachmentResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteAttachmentResult
|
||||
@@ -33,6 +36,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipher
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipherResponse
|
||||
@@ -53,6 +57,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,
|
||||
@@ -158,6 +163,76 @@ class CipherManagerImpl(
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun archiveCipher(
|
||||
cipherId: String,
|
||||
cipherView: CipherView,
|
||||
): ArchiveCipherResult {
|
||||
val userId = activeUserId ?: return ArchiveCipherResult.Error(NoActiveUserException())
|
||||
return cipherView
|
||||
.encryptCipherAndCheckForMigration(userId = userId, cipherId = cipherId)
|
||||
.flatMap { encryptionContext ->
|
||||
ciphersService
|
||||
.archiveCipher(cipherId = cipherId)
|
||||
.flatMap {
|
||||
vaultSdkSource.decryptCipher(
|
||||
userId = userId,
|
||||
cipher = encryptionContext.cipher,
|
||||
)
|
||||
}
|
||||
}
|
||||
.flatMap {
|
||||
vaultSdkSource.encryptCipher(
|
||||
userId = userId,
|
||||
cipherView = it.copy(archivedDate = clock.instant()),
|
||||
)
|
||||
}
|
||||
.onSuccess {
|
||||
vaultDiskSource.saveCipher(
|
||||
userId = userId,
|
||||
cipher = it.toEncryptedNetworkCipherResponse(),
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
onSuccess = { ArchiveCipherResult.Success },
|
||||
onFailure = { ArchiveCipherResult.Error(error = it) },
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun unarchiveCipher(
|
||||
cipherId: String,
|
||||
cipherView: CipherView,
|
||||
): UnarchiveCipherResult {
|
||||
val userId = activeUserId ?: return UnarchiveCipherResult.Error(NoActiveUserException())
|
||||
return cipherView
|
||||
.encryptCipherAndCheckForMigration(userId = userId, cipherId = cipherId)
|
||||
.flatMap { encryptionContext ->
|
||||
ciphersService
|
||||
.unarchiveCipher(cipherId = cipherId)
|
||||
.flatMap {
|
||||
vaultSdkSource.decryptCipher(
|
||||
userId = userId,
|
||||
cipher = encryptionContext.cipher,
|
||||
)
|
||||
}
|
||||
}
|
||||
.flatMap {
|
||||
vaultSdkSource.encryptCipher(
|
||||
userId = userId,
|
||||
cipherView = it.copy(archivedDate = null),
|
||||
)
|
||||
}
|
||||
.onSuccess {
|
||||
vaultDiskSource.saveCipher(
|
||||
userId = userId,
|
||||
cipher = it.toEncryptedNetworkCipherResponse(),
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
onSuccess = { UnarchiveCipherResult.Success },
|
||||
onFailure = { UnarchiveCipherResult.Error(error = it) },
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun hardDeleteCipher(cipherId: String): DeleteCipherResult {
|
||||
val userId = activeUserId
|
||||
?: return DeleteCipherResult.Error(error = NoActiveUserException())
|
||||
@@ -610,7 +685,7 @@ class CipherManagerImpl(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun migrateAttachments(
|
||||
override suspend fun migrateAttachments(
|
||||
userId: String,
|
||||
cipherView: CipherView,
|
||||
): Result<CipherView> {
|
||||
@@ -689,7 +764,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 +807,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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.vault.manager
|
||||
|
||||
import com.bitwarden.core.InitUserCryptoMethod
|
||||
import com.bitwarden.core.WrappedAccountCryptographicState
|
||||
import com.bitwarden.crypto.Kdf
|
||||
import com.bitwarden.sdk.AuthClient
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
|
||||
@@ -61,12 +62,10 @@ interface VaultLockManager {
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
suspend fun unlockVault(
|
||||
accountCryptographicState: WrappedAccountCryptographicState,
|
||||
userId: String,
|
||||
email: String,
|
||||
kdf: Kdf,
|
||||
privateKey: String,
|
||||
signingKey: String?,
|
||||
securityState: String?,
|
||||
initUserCryptoMethod: InitUserCryptoMethod,
|
||||
organizationKeys: Map<String, String>?,
|
||||
): VaultUnlockResult
|
||||
|
||||
@@ -7,8 +7,10 @@ import android.content.IntentFilter
|
||||
import com.bitwarden.core.InitOrgCryptoRequest
|
||||
import com.bitwarden.core.InitUserCryptoMethod
|
||||
import com.bitwarden.core.InitUserCryptoRequest
|
||||
import com.bitwarden.core.WrappedAccountCryptographicState
|
||||
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 +28,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
|
||||
@@ -39,6 +40,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResul
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.createWrappedAccountCryptographicState
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.logTag
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.statusFor
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.toVaultUnlockResult
|
||||
@@ -171,12 +173,10 @@ class VaultLockManagerImpl(
|
||||
|
||||
@Suppress("LongMethod")
|
||||
override suspend fun unlockVault(
|
||||
accountCryptographicState: WrappedAccountCryptographicState,
|
||||
userId: String,
|
||||
email: String,
|
||||
kdf: Kdf,
|
||||
privateKey: String,
|
||||
signingKey: String?,
|
||||
securityState: String?,
|
||||
initUserCryptoMethod: InitUserCryptoMethod,
|
||||
organizationKeys: Map<String, String>?,
|
||||
): VaultUnlockResult = withContext(context = NonCancellable) {
|
||||
@@ -187,13 +187,11 @@ class VaultLockManagerImpl(
|
||||
.initializeCrypto(
|
||||
userId = userId,
|
||||
request = InitUserCryptoRequest(
|
||||
accountCryptographicState = accountCryptographicState,
|
||||
kdfParams = kdf,
|
||||
email = email,
|
||||
privateKey = privateKey,
|
||||
method = initUserCryptoMethod,
|
||||
userId = userId,
|
||||
signingKey = signingKey,
|
||||
securityState = securityState,
|
||||
),
|
||||
)
|
||||
.flatMap { result ->
|
||||
@@ -259,29 +257,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -693,14 +681,18 @@ class VaultLockManagerImpl(
|
||||
)
|
||||
val signingKey = accountKeys?.signatureKeyPair?.wrappedSigningKey
|
||||
val securityState = accountKeys?.securityState?.securityState
|
||||
val signedPublicKey = accountKeys?.publicKeyEncryptionKeyPair?.signedPublicKey
|
||||
val organizationKeys = authDiskSource.getOrganizationKeys(userId = userId)
|
||||
return unlockVault(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = securityState,
|
||||
signingKey = signingKey,
|
||||
signedPublicKey = signedPublicKey,
|
||||
),
|
||||
userId = userId,
|
||||
email = account.profile.email,
|
||||
kdf = account.profile.toSdkParams(),
|
||||
privateKey = privateKey,
|
||||
signingKey = signingKey,
|
||||
securityState = securityState,
|
||||
initUserCryptoMethod = initUserCryptoMethod,
|
||||
organizationKeys = organizationKeys,
|
||||
)
|
||||
@@ -713,7 +705,6 @@ 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,
|
||||
@@ -721,7 +712,7 @@ class VaultLockManagerImpl(
|
||||
is InitUserCryptoMethod.KeyConnector,
|
||||
is InitUserCryptoMethod.Pin,
|
||||
is InitUserCryptoMethod.PinEnvelope,
|
||||
-> return
|
||||
-> return
|
||||
}
|
||||
|
||||
kdfManager
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.x8bit.bitwarden.data.vault.manager
|
||||
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.VaultMigrationData
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.MigratePersonalVaultResult
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Manages the migration of personal vault items to organization collections.
|
||||
* This interface provides a way to check if migration is needed and track migration state.
|
||||
*
|
||||
* The manager reactively observes vault cipher data and automatically updates the migration state
|
||||
* when conditions change (e.g., after sync, after vault unlock, policy changes).
|
||||
*/
|
||||
interface VaultMigrationManager {
|
||||
/**
|
||||
* Flow that emits when conditions are met for the user to migrate their personal vault.
|
||||
* Automatically updated when cipher data, policies, or feature flags change.
|
||||
*/
|
||||
val vaultMigrationDataStateFlow: StateFlow<VaultMigrationData>
|
||||
|
||||
/**
|
||||
* Migrates all personal vault items to the specified organization.
|
||||
*
|
||||
* @param userId The ID of the user performing the migration.
|
||||
* @param organizationId The ID of the organization to migrate items to.
|
||||
* @return Result indicating success or failure of the migration operation.
|
||||
*/
|
||||
suspend fun migratePersonalVault(
|
||||
userId: String,
|
||||
organizationId: String,
|
||||
): MigratePersonalVaultResult
|
||||
|
||||
/**
|
||||
* Clears the migration state, setting it to [VaultMigrationData.NoMigrationRequired].
|
||||
* This should be called when the user declines migration or leaves the organization.
|
||||
*/
|
||||
fun clearMigrationState()
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package com.x8bit.bitwarden.data.vault.manager
|
||||
|
||||
import com.bitwarden.collections.CollectionType
|
||||
import com.bitwarden.collections.CollectionView
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.core.data.manager.model.FlagKey
|
||||
import com.bitwarden.core.data.util.asFailure
|
||||
import com.bitwarden.core.data.util.asSuccess
|
||||
import com.bitwarden.core.data.util.flatMap
|
||||
import com.bitwarden.network.model.BulkShareCiphersJsonRequest
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.network.model.SyncResponseJson
|
||||
import com.bitwarden.network.model.toCipherWithIdJsonRequest
|
||||
import com.bitwarden.network.service.CiphersService
|
||||
import com.bitwarden.vault.CipherView
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.observeWhenSubscribedAndUnlocked
|
||||
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.GetCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.VaultMigrationData
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.MigratePersonalVaultResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipher
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.updateFromMiniResponse
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Default implementation of [VaultMigrationManager].
|
||||
*
|
||||
* Reactively observes vault cipher data and automatically updates migration state when:
|
||||
* - Vault is unlocked
|
||||
* - Sync has occurred at least once
|
||||
* - Cipher data changes
|
||||
* - Network connectivity changes
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
class VaultMigrationManagerImpl(
|
||||
private val authDiskSource: AuthDiskSource,
|
||||
private val vaultDiskSource: VaultDiskSource,
|
||||
private val vaultRepository: VaultRepository,
|
||||
private val vaultSdkSource: VaultSdkSource,
|
||||
private val ciphersService: CiphersService,
|
||||
private val settingsDiskSource: SettingsDiskSource,
|
||||
private val policyManager: PolicyManager,
|
||||
private val featureFlagManager: FeatureFlagManager,
|
||||
private val connectionManager: NetworkConnectionManager,
|
||||
vaultLockManager: VaultLockManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
) : VaultMigrationManager {
|
||||
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
|
||||
|
||||
private val mutableVaultMigrationDataStateFlow =
|
||||
MutableStateFlow<VaultMigrationData>(value = VaultMigrationData.NoMigrationRequired)
|
||||
|
||||
override val vaultMigrationDataStateFlow: StateFlow<VaultMigrationData>
|
||||
get() = mutableVaultMigrationDataStateFlow.asStateFlow()
|
||||
|
||||
init {
|
||||
// Observe cipher data changes and automatically verify migration state
|
||||
mutableVaultMigrationDataStateFlow
|
||||
.observeWhenSubscribedAndUnlocked(
|
||||
userStateFlow = authDiskSource.userStateFlow,
|
||||
vaultUnlockFlow = vaultLockManager.vaultUnlockDataStateFlow,
|
||||
) { activeUserId ->
|
||||
observeCipherDataAndUpdateMigrationState(userId = activeUserId)
|
||||
}
|
||||
.launchIn(unconfinedScope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes cipher data, sync state, and network connectivity for the given user and updates
|
||||
* migration state when changes occur. Only emits updates after the user has synced at least
|
||||
* once to ensure data freshness.
|
||||
*
|
||||
* Uses optimized [VaultDiskSource.hasPersonalCiphersFlow] query that checks only the
|
||||
* indexed organizationId column without loading full cipher JSON data.
|
||||
*
|
||||
* Combines cipher data with [SettingsDiskSource.getLastSyncTimeFlow] to handle multi-account
|
||||
* scenarios where lastSyncTime may be cleared without clearing cipher data. This ensures
|
||||
* migration state updates when sync completes, not just when cipher data changes.
|
||||
*
|
||||
* Also combines with [NetworkConnectionManager.isNetworkConnectedFlow] to ensure migration
|
||||
* state updates reactively when network connectivity changes.
|
||||
*/
|
||||
private fun observeCipherDataAndUpdateMigrationState(userId: String) =
|
||||
combine(
|
||||
vaultDiskSource.hasPersonalCiphersFlow(userId = userId),
|
||||
settingsDiskSource.getLastSyncTimeFlow(userId = userId),
|
||||
connectionManager.isNetworkConnectedFlow,
|
||||
) { hasPersonalCiphers, lastSyncTime, isNetworkConnected ->
|
||||
// Only process after sync has occurred at least once
|
||||
lastSyncTime ?: return@combine null
|
||||
hasPersonalCiphers to isNetworkConnected
|
||||
}
|
||||
.filterNotNull()
|
||||
.onEach { (hasPersonalCiphers, isNetworkConnected) ->
|
||||
verifyAndUpdateMigrationState(
|
||||
userId = userId,
|
||||
hasPersonalCiphers = hasPersonalCiphers,
|
||||
isNetworkConnected = isNetworkConnected,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies if the user should migrate their personal vault to organization collections
|
||||
* based on active policies, feature flags, network connectivity, and whether they have
|
||||
* personal ciphers.
|
||||
*
|
||||
* @param userId The ID of the user to check for migration.
|
||||
* @param hasPersonalCiphers Boolean indicating if the user has any personal ciphers.
|
||||
* @param isNetworkConnected Boolean indicating if the device has network connectivity.
|
||||
*/
|
||||
private fun verifyAndUpdateMigrationState(
|
||||
userId: String,
|
||||
hasPersonalCiphers: Boolean,
|
||||
isNetworkConnected: Boolean,
|
||||
) {
|
||||
mutableVaultMigrationDataStateFlow.update {
|
||||
if (!shouldMigrateVault(
|
||||
hasPersonalCiphers = hasPersonalCiphers,
|
||||
isNetworkConnected = isNetworkConnected,
|
||||
)
|
||||
) {
|
||||
return@update VaultMigrationData.NoMigrationRequired
|
||||
}
|
||||
|
||||
val orgId = policyManager.getPersonalOwnershipPolicyOrganizationId()
|
||||
?: return@update VaultMigrationData.NoMigrationRequired
|
||||
|
||||
val orgName = authDiskSource
|
||||
.getOrganizations(userId = userId)
|
||||
?.firstOrNull { it.id == orgId }
|
||||
?.name
|
||||
?: return@update VaultMigrationData.NoMigrationRequired
|
||||
|
||||
VaultMigrationData.MigrationRequired(
|
||||
organizationId = orgId,
|
||||
organizationName = orgName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user should migrate their vault based on policies, feature flags,
|
||||
* network connectivity, and whether they have personal items.
|
||||
*
|
||||
* @param hasPersonalCiphers Boolean indicating if the user has any personal ciphers.
|
||||
* @param isNetworkConnected Boolean indicating if the device has network connectivity.
|
||||
* @return true if migration conditions are met, false otherwise.
|
||||
*/
|
||||
private fun shouldMigrateVault(
|
||||
hasPersonalCiphers: Boolean,
|
||||
isNetworkConnected: Boolean,
|
||||
): Boolean =
|
||||
policyManager
|
||||
.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
.any() &&
|
||||
featureFlagManager.getFeatureFlag(FlagKey.MigrateMyVaultToMyItems) &&
|
||||
isNetworkConnected &&
|
||||
hasPersonalCiphers
|
||||
|
||||
override suspend fun migratePersonalVault(
|
||||
userId: String,
|
||||
organizationId: String,
|
||||
): MigratePersonalVaultResult {
|
||||
val vaultData = vaultRepository.vaultDataStateFlow.value.data
|
||||
?: return MigratePersonalVaultResult.Failure(
|
||||
IllegalStateException("Vault data not available"),
|
||||
)
|
||||
|
||||
val defaultUserCollection = getDefaultUserCollection(vaultData, organizationId)
|
||||
.getOrElse { return MigratePersonalVaultResult.Failure(it) }
|
||||
|
||||
val personalCiphers = getPersonalCipherViews(vaultData)
|
||||
.getOrElse { return MigratePersonalVaultResult.Failure(it) }
|
||||
|
||||
if (personalCiphers.isEmpty()) {
|
||||
clearMigrationState()
|
||||
return MigratePersonalVaultResult.Success
|
||||
}
|
||||
|
||||
val cipherIds = personalCiphers.mapNotNull { it.id }
|
||||
val encryptedCiphers = vaultDiskSource.getSelectedCiphers(
|
||||
userId = userId,
|
||||
cipherIds = cipherIds,
|
||||
)
|
||||
val encryptedCiphersMap = encryptedCiphers.associateBy { it.id }
|
||||
|
||||
val processedCipherViews = migrateAttachments(userId, personalCiphers)
|
||||
.getOrElse { return MigratePersonalVaultResult.Failure(it) }
|
||||
|
||||
encryptAndShareCiphers(
|
||||
userId = userId,
|
||||
organizationId = organizationId,
|
||||
processedCipherViews = processedCipherViews,
|
||||
encryptedCiphersMap = encryptedCiphersMap,
|
||||
collectionIds = listOfNotNull(defaultUserCollection.id),
|
||||
).getOrElse { return MigratePersonalVaultResult.Failure(it) }
|
||||
|
||||
clearMigrationState()
|
||||
return MigratePersonalVaultResult.Success
|
||||
}
|
||||
|
||||
override fun clearMigrationState() {
|
||||
mutableVaultMigrationDataStateFlow.update { VaultMigrationData.NoMigrationRequired }
|
||||
}
|
||||
|
||||
private fun getDefaultUserCollection(
|
||||
vaultData: VaultData,
|
||||
organizationId: String,
|
||||
): Result<CollectionView> {
|
||||
val collection = vaultData.collectionViewList.find {
|
||||
it.type == CollectionType.DEFAULT_USER_COLLECTION && it.organizationId == organizationId
|
||||
}
|
||||
return collection?.asSuccess()
|
||||
?: IllegalStateException("Default user collection not found for organization")
|
||||
.asFailure()
|
||||
}
|
||||
|
||||
private suspend fun getPersonalCipherViews(
|
||||
vaultData: VaultData,
|
||||
): Result<List<CipherView>> = runCatching {
|
||||
vaultData.decryptCipherListResult.successes
|
||||
.filter { it.organizationId == null }
|
||||
.mapNotNull { cipherListView ->
|
||||
cipherListView.id?.let { cipherId ->
|
||||
vaultRepository
|
||||
.getCipher(cipherId = cipherId)
|
||||
.toCipherViewOrFailure()
|
||||
?.getOrElse { error ->
|
||||
return error.asFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun migrateAttachments(
|
||||
userId: String,
|
||||
personalCiphers: List<CipherView>,
|
||||
): Result<List<CipherView>> = runCatching {
|
||||
personalCiphers.map { cipherView ->
|
||||
vaultRepository
|
||||
.migrateAttachments(userId = userId, cipherView = cipherView)
|
||||
.getOrElse { error ->
|
||||
return error.asFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun encryptAndShareCiphers(
|
||||
userId: String,
|
||||
organizationId: String,
|
||||
processedCipherViews: List<CipherView>,
|
||||
encryptedCiphersMap: Map<String, SyncResponseJson.Cipher>,
|
||||
collectionIds: List<String>,
|
||||
): Result<Unit> {
|
||||
return vaultSdkSource
|
||||
.bulkMoveToOrganization(
|
||||
userId = userId,
|
||||
organizationId = organizationId,
|
||||
cipherViews = processedCipherViews,
|
||||
collectionIds = collectionIds,
|
||||
)
|
||||
.map { encryptionContexts ->
|
||||
encryptionContexts.mapNotNull { context ->
|
||||
context.cipher.id?.let { cipherId ->
|
||||
context
|
||||
.toEncryptedNetworkCipher()
|
||||
.toCipherWithIdJsonRequest(id = cipherId)
|
||||
}
|
||||
}
|
||||
}
|
||||
.flatMap { cipherRequests ->
|
||||
ciphersService.bulkShareCiphers(
|
||||
body = BulkShareCiphersJsonRequest(
|
||||
ciphers = cipherRequests,
|
||||
collectionIds = collectionIds,
|
||||
),
|
||||
)
|
||||
}
|
||||
.map { bulkShareResponse ->
|
||||
bulkShareResponse.cipherMiniResponse.forEach { miniResponse ->
|
||||
encryptedCiphersMap[miniResponse.id]?.let {
|
||||
vaultDiskSource.saveCipher(
|
||||
userId = userId,
|
||||
cipher = it.updateFromMiniResponse(
|
||||
miniResponse = miniResponse,
|
||||
collectionIds = collectionIds,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun GetCipherResult.toCipherViewOrFailure(): Result<CipherView>? =
|
||||
when (this) {
|
||||
GetCipherResult.CipherNotFound -> {
|
||||
Timber.e("Cipher not found for vault migration.")
|
||||
null
|
||||
}
|
||||
|
||||
is GetCipherResult.Failure -> {
|
||||
Timber.e(this.error, "Failed to decrypt cipher for vault migration.")
|
||||
this.error.asFailure()
|
||||
}
|
||||
|
||||
is GetCipherResult.Success -> this.cipherView.asSuccess()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -17,9 +17,11 @@ import com.x8bit.bitwarden.data.auth.manager.UserStateManager
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.manager.AppStateManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.PushManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||
@@ -27,8 +29,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
|
||||
@@ -39,8 +39,11 @@ import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.TotpCodeManagerImpl
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultLockManagerImpl
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManagerImpl
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManagerImpl
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -57,10 +60,39 @@ import javax.inject.Singleton
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object VaultManagerModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideVaultMigrationManager(
|
||||
authDiskSource: AuthDiskSource,
|
||||
vaultDiskSource: VaultDiskSource,
|
||||
vaultRepository: VaultRepository,
|
||||
vaultSdkSource: VaultSdkSource,
|
||||
ciphersService: CiphersService,
|
||||
settingsDiskSource: SettingsDiskSource,
|
||||
vaultLockManager: VaultLockManager,
|
||||
policyManager: PolicyManager,
|
||||
featureFlagManager: FeatureFlagManager,
|
||||
connectionManager: NetworkConnectionManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
): VaultMigrationManager = VaultMigrationManagerImpl(
|
||||
authDiskSource = authDiskSource,
|
||||
vaultDiskSource = vaultDiskSource,
|
||||
vaultRepository = vaultRepository,
|
||||
vaultSdkSource = vaultSdkSource,
|
||||
ciphersService = ciphersService,
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
vaultLockManager = vaultLockManager,
|
||||
policyManager = policyManager,
|
||||
featureFlagManager = featureFlagManager,
|
||||
connectionManager = connectionManager,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCipherManager(
|
||||
ciphersService: CiphersService,
|
||||
settingsDiskSource: SettingsDiskSource,
|
||||
vaultDiskSource: VaultDiskSource,
|
||||
vaultSdkSource: VaultSdkSource,
|
||||
authDiskSource: AuthDiskSource,
|
||||
@@ -71,6 +103,7 @@ object VaultManagerModule {
|
||||
pushManager: PushManager,
|
||||
): CipherManager = CipherManagerImpl(
|
||||
fileManager = fileManager,
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
authDiskSource = authDiskSource,
|
||||
ciphersService = ciphersService,
|
||||
vaultDiskSource = vaultDiskSource,
|
||||
@@ -85,6 +118,7 @@ object VaultManagerModule {
|
||||
@Singleton
|
||||
fun provideFolderManager(
|
||||
folderService: FolderService,
|
||||
settingsDiskSource: SettingsDiskSource,
|
||||
vaultDiskSource: VaultDiskSource,
|
||||
vaultSdkSource: VaultSdkSource,
|
||||
authDiskSource: AuthDiskSource,
|
||||
@@ -92,6 +126,7 @@ object VaultManagerModule {
|
||||
pushManager: PushManager,
|
||||
): FolderManager = FolderManagerImpl(
|
||||
authDiskSource = authDiskSource,
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
folderService = folderService,
|
||||
vaultDiskSource = vaultDiskSource,
|
||||
vaultSdkSource = vaultSdkSource,
|
||||
@@ -106,6 +141,7 @@ object VaultManagerModule {
|
||||
vaultDiskSource: VaultDiskSource,
|
||||
vaultSdkSource: VaultSdkSource,
|
||||
authDiskSource: AuthDiskSource,
|
||||
settingsDiskSource: SettingsDiskSource,
|
||||
fileManager: FileManager,
|
||||
reviewPromptManager: ReviewPromptManager,
|
||||
pushManager: PushManager,
|
||||
@@ -113,6 +149,7 @@ object VaultManagerModule {
|
||||
): SendManager = SendManagerImpl(
|
||||
fileManager = fileManager,
|
||||
authDiskSource = authDiskSource,
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
sendsService = sendsService,
|
||||
vaultDiskSource = vaultDiskSource,
|
||||
vaultSdkSource = vaultSdkSource,
|
||||
@@ -121,18 +158,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(
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.x8bit.bitwarden.data.vault.manager.model
|
||||
|
||||
/**
|
||||
* Represents vault migration state with organization metadata.
|
||||
*/
|
||||
sealed class VaultMigrationData {
|
||||
/**
|
||||
* User should migrate personal vault items to the specified organization.
|
||||
*/
|
||||
data class MigrationRequired(
|
||||
val organizationId: String,
|
||||
val organizationName: String,
|
||||
) : VaultMigrationData()
|
||||
|
||||
/**
|
||||
* No migration required.
|
||||
*/
|
||||
data object NoMigrationRequired : VaultMigrationData()
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.x8bit.bitwarden.data.vault.repository
|
||||
|
||||
import com.bitwarden.core.DateTime
|
||||
import com.bitwarden.core.data.repository.model.DataState
|
||||
import com.bitwarden.exporters.ExportFormat
|
||||
import com.bitwarden.fido.Fido2CredentialAutofillView
|
||||
@@ -24,6 +23,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.time.Instant
|
||||
import javax.crypto.Cipher
|
||||
|
||||
/**
|
||||
@@ -92,6 +92,7 @@ interface VaultRepository :
|
||||
userId: String,
|
||||
fido2CredentialStore: Fido2CredentialStore,
|
||||
relyingPartyId: String,
|
||||
userHandle: String?,
|
||||
): Result<List<Fido2CredentialAutofillView>>
|
||||
|
||||
/**
|
||||
@@ -134,7 +135,7 @@ interface VaultRepository :
|
||||
/**
|
||||
* Attempt to get the verification code and the period.
|
||||
*/
|
||||
suspend fun generateTotp(cipherId: String, time: DateTime): GenerateTotpResult
|
||||
suspend fun generateTotp(cipherId: String, time: Instant): GenerateTotpResult
|
||||
|
||||
/**
|
||||
* Attempt to get the user's vault data for export.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 +21,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
|
||||
@@ -41,6 +40,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.ImportCredentialsResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.createWrappedAccountCryptographicState
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolder
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.toSdkAccount
|
||||
@@ -62,6 +62,7 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.security.GeneralSecurityException
|
||||
import java.time.Instant
|
||||
import javax.crypto.Cipher
|
||||
|
||||
/**
|
||||
@@ -253,12 +254,14 @@ class VaultRepositoryImpl(
|
||||
userId: String,
|
||||
fido2CredentialStore: Fido2CredentialStore,
|
||||
relyingPartyId: String,
|
||||
userHandle: String?,
|
||||
): Result<List<Fido2CredentialAutofillView>> =
|
||||
vaultSdkSource
|
||||
.silentlyDiscoverCredentials(
|
||||
userId = userId,
|
||||
fido2CredentialStore = fido2CredentialStore,
|
||||
relyingPartyId = relyingPartyId,
|
||||
userHandle = userHandle,
|
||||
)
|
||||
|
||||
override fun emitTotpCodeResult(totpCodeResult: TotpCodeResult) {
|
||||
@@ -341,25 +344,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,
|
||||
@@ -411,7 +408,7 @@ class VaultRepositoryImpl(
|
||||
|
||||
override suspend fun generateTotp(
|
||||
cipherId: String,
|
||||
time: DateTime,
|
||||
time: Instant,
|
||||
): GenerateTotpResult {
|
||||
val userId = activeUserId
|
||||
?: return GenerateTotpResult.Error(error = NoActiveUserException())
|
||||
@@ -543,15 +540,19 @@ class VaultRepositoryImpl(
|
||||
)
|
||||
val signingKey = accountKeys?.signatureKeyPair?.wrappedSigningKey
|
||||
val securityState = accountKeys?.securityState?.securityState
|
||||
val signedPublicKey = accountKeys?.publicKeyEncryptionKeyPair?.signedPublicKey
|
||||
val organizationKeys = authDiskSource
|
||||
.getOrganizationKeys(userId = userId)
|
||||
return vaultLockManager.unlockVault(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = securityState,
|
||||
signingKey = signingKey,
|
||||
signedPublicKey = signedPublicKey,
|
||||
),
|
||||
userId = userId,
|
||||
email = account.profile.email,
|
||||
kdf = account.profile.toSdkParams(),
|
||||
privateKey = privateKey,
|
||||
signingKey = signingKey,
|
||||
securityState = securityState,
|
||||
initUserCryptoMethod = initUserCryptoMethod,
|
||||
organizationKeys = organizationKeys,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.x8bit.bitwarden.data.vault.repository.model
|
||||
|
||||
/**
|
||||
* Models result of archiving a cipher.
|
||||
*/
|
||||
sealed class ArchiveCipherResult {
|
||||
|
||||
/**
|
||||
* Cipher archived successfully.
|
||||
*/
|
||||
data object Success : ArchiveCipherResult()
|
||||
|
||||
/**
|
||||
* Generic error while archiving a cipher.
|
||||
*/
|
||||
data class Error(val error: Throwable) : ArchiveCipherResult()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.x8bit.bitwarden.data.vault.repository.model
|
||||
|
||||
/**
|
||||
* Models result of migrating the personal vault.
|
||||
*/
|
||||
sealed class MigratePersonalVaultResult {
|
||||
/**
|
||||
* Personal vault migrated successfully.
|
||||
*/
|
||||
data object Success : MigratePersonalVaultResult()
|
||||
|
||||
/**
|
||||
* Generic error while migrating personal vault
|
||||
*/
|
||||
data class Failure(val error: Throwable?) : MigratePersonalVaultResult()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.x8bit.bitwarden.data.vault.repository.model
|
||||
|
||||
/**
|
||||
* Models result of unarchiving a cipher.
|
||||
*/
|
||||
sealed class UnarchiveCipherResult {
|
||||
|
||||
/**
|
||||
* Cipher unarchived successfully.
|
||||
*/
|
||||
data object Success : UnarchiveCipherResult()
|
||||
|
||||
/**
|
||||
* Generic error while unarchiving a cipher.
|
||||
*/
|
||||
data class Error(val error: Throwable) : UnarchiveCipherResult()
|
||||
}
|
||||
@@ -12,7 +12,6 @@ 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"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user