Compare commits

...

69 Commits

Author SHA1 Message Date
Álison Fernandes
619c6efcf4 Update readme with screenshots, workflow badges and links to our docs 2025-03-26 15:53:07 +00:00
David Perez
b4b4f753ca PM-8953: Require 4 digits for pin entry (#4914) 2025-03-25 21:20:15 +00:00
David Perez
22376bfe4b PM-19403: Add better error messages for username generation (#4911) 2025-03-24 18:32:24 +00:00
David Perez
ab270cd243 PM-19466: Handle the IME padding internal to the BitwardenScaffold (#4912) 2025-03-24 17:13:35 +00:00
David Perez
8ed9b97805 Update protobuf lib to 4.30.1 (#4909) 2025-03-21 21:18:01 +00:00
David Perez
27e4c6a2b4 Update Compose BOM to 2025.03.00 (#4908) 2025-03-21 21:17:41 +00:00
David Perez
a6ed702a95 BWA-154: Fix privacy policy padding (#4907) 2025-03-21 20:25:06 +00:00
David Perez
0792f44b6b Update to Firebase 33.11.0 (#4906) 2025-03-21 18:36:54 +00:00
David Perez
94a91702cc PM-19399: Do not show 'Share error details' button when user enter incorrect password (#4905) 2025-03-21 16:29:38 +00:00
David Perez
21af60f4de Update to Junit 5.12.1 (#4903) 2025-03-21 16:11:12 +00:00
Matt Andreko
1add57d56c Fix SARIF upload branch ref/sha (#4899) 2025-03-21 13:13:00 +00:00
bw-ghapp[bot]
b0421c774b Autosync Crowdin Translations (#4902)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-03-21 13:06:20 +00:00
David Perez
7d846ae383 PM-18862: Update IME handling for BitwardenScaffold and VaultUnlockedNavBarScreen (#4901) 2025-03-20 20:44:46 +00:00
David Perez
29371bdcb5 PM-19389: Handle encoding error when migrating biometric key (#4900) 2025-03-20 19:15:44 +00:00
Álison Fernandes
3eed1c1abe [PM-19049] Add workflow to regularly fetch updates to fido2_privileged_google.json (#4858)
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
2025-03-19 21:13:40 +00:00
aj-rosado
ad6bc883b8 [PM-13257] Checking if user is navigating from vault before showing prompt for biometrics (#4846) 2025-03-19 10:23:50 +00:00
David Perez
f4f669683e PM-19334: Propagate errors to the UI (#4893) 2025-03-18 17:27:41 +00:00
Dave Severns
475a82e0fb PM-19335 add throwable val to generator error results (#4894) 2025-03-18 17:08:11 +00:00
Phil Cappelli
f22156389b BWA-119 - Unable to Access Manual Code Entry After Denying Camera Permissions on (#4891) 2025-03-18 15:50:51 +00:00
Phil Cappelli
4f09f5dae4 PM-18872 - When a Folder name is long, the View/Edit Item > Folder selection screen doesn't adjust well (#4892) 2025-03-18 15:50:39 +00:00
David Perez
3934bc9ae2 PM-19314: Propagate remaining auth errors to the UI (#4888) 2025-03-18 15:24:17 +00:00
David Perez
72c9149d27 PM-19295: Propagate password errors to the UI (#4884) 2025-03-18 14:05:36 +00:00
David Perez
a040a38ce8 PM-19296: Propagate login errors to the UI (#4885) 2025-03-18 14:05:07 +00:00
Dave Severns
ef3b7730d0 PM-19289 propagating remaining vault result errors. (#4881) 2025-03-18 13:51:30 +00:00
David Perez
ad8d8d271a PM-19294: Propagate the Register errors to the UI (#4883) 2025-03-17 19:54:43 +00:00
David Perez
4954e57007 Update gem dependencies (#4882) 2025-03-17 19:29:22 +00:00
Dave Severns
6f50fffd17 PM-19275 propagate the errors for the vault unlock error result types (#4878) 2025-03-17 19:24:13 +00:00
David Perez
44c5755301 PM-19284: Propagate SSO flow errors to the UI (#4880) 2025-03-17 19:14:17 +00:00
David Perez
b20eece3aa PM-19283: Propagate error from email token and known device flows (#4879) 2025-03-17 17:47:07 +00:00
renovate[bot]
869a3b00a5 [deps]: Lock file maintenance (#4875)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-17 15:47:03 +00:00
David Perez
7f8e848c46 PM-19245: Propagate error from password validation to UI (#4877) 2025-03-17 15:36:01 +00:00
Dave Severns
6c784d28eb PM-19272 propagate errors from cipher results (#4876) 2025-03-17 15:18:05 +00:00
Dave Severns
dfcdc72499 PM-19241 folder result errors propagated to UI (#4870) 2025-03-17 12:20:44 +00:00
Dave Severns
db287ddce5 PM-19243 send result errors propagated to UI (#4872) 2025-03-14 22:01:42 +00:00
David Perez
9ea85917b1 PM-19239: Propagate delete account errors to the UI (#4871) 2025-03-14 21:27:56 +00:00
Dave Severns
6db4165c4c PM-19234 propagates attachment result errors to UI (#4869) 2025-03-14 20:47:25 +00:00
David Perez
18ce45e7e5 PM-19233: Propagate auth request errors to the UI (#4868) 2025-03-14 18:33:39 +00:00
Dave Severns
6fe9eba620 PM-11356 Adjust autofocus delay to be greater than screen refresh delay. (#4866) 2025-03-14 16:48:34 +00:00
David Perez
b084987758 PM-19226: Propagate error from create auth request flow to UI (#4867) 2025-03-14 15:47:03 +00:00
bw-ghapp[bot]
5d0593026f Autosync Crowdin Translations (#4865)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-03-14 14:39:07 +00:00
David Perez
47abeb7843 PM-14435: Improve accessibility service detection (#4864) 2025-03-14 14:16:25 +00:00
Phil Cappelli
e2779e4edb PM-18681 - Update Showing Coach Mark Tour Logic To Only Consider User's Personal Vault (#4863) 2025-03-13 20:46:14 +00:00
Álison Fernandes
90cc9f77c5 [PM-19207] Add Passkey / FIDO2 Bug Report template (#4859) 2025-03-13 20:31:30 +00:00
David Perez
40760f270a Use immutable map in debug menu (#4861) 2025-03-13 19:28:27 +00:00
David Perez
8a773141a4 Simplify RootNavScreenTest (#4860) 2025-03-13 19:20:14 +00:00
David Perez
0c149abdd9 PM-18844: Update BitwardenBasicDialog to allow it to share error logs (#4855) 2025-03-13 18:16:22 +00:00
David Perez
f540f86b19 PM-19199: hoist debug menu up to top level of the app (#4857) 2025-03-13 17:51:22 +00:00
Dave Severns
ca64ce2176 PM-18877 Respect system app specific language selection on Android 13 and up. (#4849) 2025-03-13 13:14:41 +00:00
André Bispo
da63c9e36b [PM-17995] Adjust custom fields section (#4835) 2025-03-13 11:33:39 +00:00
André Bispo
e16ad44d5e [PM-17242] While on autofill search on all item types. (#4824) 2025-03-13 11:33:12 +00:00
Phil Cappelli
d26a2ee52a PM-18681 - Update Showing Coach Mark Tour Logic To Account for Org Only Policy (#4854) 2025-03-12 20:20:44 +00:00
Dave Severns
1eb741ab58 PM-11356 prevent extra soft-keyboard showing. (#4845) 2025-03-11 20:10:46 +00:00
David Perez
e10ca9a6ec PM-19099: Centralize app metadata (#4847) 2025-03-11 19:47:07 +00:00
David Perez
3fca61ad3e Remove the language change dialog (#4658) 2025-03-11 14:33:22 +00:00
David Perez
409529b9ca Update AndroidX Activity to 1.10.1 (#4844) 2025-03-11 13:51:39 +00:00
David Perez
4568dd53d4 Update Firebase BOM to 33.10.0 (#4843) 2025-03-10 21:20:56 +00:00
David Perez
b9b90165bf PM-10725: Always show share sheet after creating send regardless of how it was made (#4841) 2025-03-10 20:43:49 +00:00
David Perez
778a630012 Update to AGP 8.9.0 (#4840) 2025-03-10 15:46:30 +00:00
Dave Severns
4809066ad7 PM-17087 update notification payloads to support camelCase JSON keys. (#4823) 2025-03-10 14:54:58 +00:00
Patrick Honkonen
d03c6c243d [PM-18873] Refactor ItemHeader.kt to improve location display (#4814) 2025-03-07 17:02:15 +00:00
bw-ghapp[bot]
d19ab498ff Autosync Crowdin Translations (#4832)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-03-07 16:48:31 +00:00
Phil Cappelli
efc3d21fde PM-18681 - Update Showing Coach Mark Tour Logic To Only Consider User's Personal Vault (#4821) 2025-03-05 16:01:41 +00:00
Phil Cappelli
35e585a60e PM-18570 Update Owner Selection Field to Bottom Sheet Selector (#4810) 2025-03-04 22:24:31 +00:00
Patrick Honkonen
39787f9bf0 [deps] Update mockk to 1.13.17 (#4818) 2025-03-04 20:17:42 +00:00
Patrick Honkonen
3940997ef9 [deps] Update junit5 to 5.11.4 (#4819) 2025-03-04 20:17:23 +00:00
Patrick Honkonen
ce482e744d [deps] Update testng to 7.11.0 (#4820) 2025-03-04 19:36:18 +00:00
Patrick Honkonen
b0157d10e2 [deps] Update detekt to 1.23.8 (#4817) 2025-03-04 19:36:00 +00:00
renovate[bot]
cf3c2fb56d [deps]: migrate renovate config (#4815)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-04 17:56:09 +00:00
Dave Severns
a88a173e00 PM-18773 update the keyName for the ChromeAutofill flag (#4812) 2025-03-03 15:31:02 +00:00
367 changed files with 4767 additions and 3748 deletions

64
.github/ISSUE_TEMPLATE/bug-passkey.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
name: Passkey Bug Report
description: File a Passkey / FIDO2 related bug report
labels: [ "app:password-manager", "bug-passkey" ]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this Passkey-related bug report!
Please provide as much detail as possible to help us investigate the issue.
- type: dropdown
id: origin
attributes:
label: Origin
description: Are you using a web browser or a native application?
options:
- Web (Browser)
- Native Application (non-browser app)
validations:
required: true
- type: input
id: rp-id
attributes:
label: Web URL or App name
description: The website domain or app name you were trying to use the Passkey with
placeholder: "e.g. example.com or ExampleApp"
validations:
required: true
- type: checkboxes
id: operation-type
attributes:
label: Passkey Action
description: What passkey related action(s) were you trying to perform?
options:
- label: Creating new passkey (Registration)
- label: Signing in (Authentication)
validations:
required: true
- type: textarea
id: build-info
attributes:
label: Build Information
description: Please retrieve the build information from the About screen by tapping the Version number field
validations:
required: true
- type: textarea
id: additional-info
attributes:
label: Additional Information
description: Any additional context, steps to reproduce, error messages, or relevant information about the issue
- type: checkboxes
id: issue-tracking-info
attributes:
label: Issue Tracking Info
description: |
Issue tracking information
options:
- label: I understand that work is tracked outside of Github. A PR will be linked to this issue should one be opened to address it, but Bitwarden doesn't use fields like "assigned", "milestone", or "project" to track progress.

BIN
.github/images/android-dark.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

BIN
.github/images/android-light.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

51
.github/renovate.json vendored
View File

@@ -1,33 +1,56 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["github>bitwarden/renovate-config"],
"enabledManagers": ["github-actions", "gradle", "bundler"],
"extends": [
"github>bitwarden/renovate-config"
],
"enabledManagers": [
"github-actions",
"gradle",
"bundler"
],
"packageRules": [
{
"groupName": "gh minor",
"matchManagers": ["github-actions"],
"matchUpdateTypes": ["minor", "patch"]
"matchManagers": [
"github-actions"
],
"matchUpdateTypes": [
"minor",
"patch"
]
},
{
"groupName": "gradle minor",
"matchUpdateTypes": ["minor", "patch"],
"matchManagers": ["gradle"]
"matchUpdateTypes": [
"minor",
"patch"
],
"matchManagers": [
"gradle"
]
},
{
"groupName": "kotlin",
"description": "Kotlin and Compose dependencies that must be updated together to maintain compatibility.",
"matchPackagePatterns": [
"androidx.compose:compose-bom",
"androidx.lifecycle:*",
"org.jetbrains.kotlin.*",
"com.google.devtools.ksp"
"matchManagers": [
"gradle"
],
"matchManagers": ["gradle"]
"matchPackageNames": [
"/androidx.compose:compose-bom/",
"/androidx.lifecycle:*/",
"/org.jetbrains.kotlin.*/",
"/com.google.devtools.ksp/"
]
},
{
"groupName": "bundler minor",
"matchUpdateTypes": ["minor", "patch"],
"matchManagers": ["bundler"]
"matchUpdateTypes": [
"minor",
"patch"
],
"matchManagers": [
"bundler"
]
}
]
}

View File

@@ -0,0 +1 @@
3.13

39
.github/scripts/validate-json/README.md vendored Normal file
View File

@@ -0,0 +1,39 @@
# JSON Validation Scripts
Utility scripts for validating JSON files and checking for duplicate package names between Google and Community privileged browser lists.
## Usage
### Validate a JSON file
```bash
python validate_json.py validate <json_file>
```
### Check for duplicates between two JSON files
```bash
python validate_json.py duplicates <json_file1> <json_file2> [output_file]
```
If `output_file` is not specified, duplicates will be saved to `duplicates.txt`.
## Running Tests
```bash
# Run all tests
python -m unittest test_validate_json.py
# Run the invalid JSON test individually
python -m unittest test_validate_json.TestValidateJson.test_validate_json_invalid
```
## Examples
```bash
# Validate Google privileged browsers list
python validate_json.py validate ../../app/src/main/assets/fido2_privileged_google.json
# Check for duplicates between Google and Community lists
python validate_json.py duplicates ../../app/src/main/assets/fido2_privileged_google.json ../../app/src/main/assets/fido2_privileged_community.json duplicates.txt
```

View File

@@ -0,0 +1,20 @@
{
"apps": [
"type": "android",
"info": {
"package_name": "com.android.chrome",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83"
},
{
"build": "userdebug",
"cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00"
}
]
}
}
]
}

View File

@@ -0,0 +1,48 @@
{
"apps": [
{
"type": "android",
"info": {
"package_name": "com.android.chrome",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83"
},
{
"build": "userdebug",
"cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.chrome.dev",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "90:44:EE:5F:EE:4B:BC:5E:21:DD:44:66:54:31:C4:EB:1F:1F:71:A3:27:16:A0:BC:92:7B:CB:B3:92:33:CA:BF"
},
{
"build": "release",
"cert_fingerprint_sha256": "3D:7A:12:23:01:9A:A3:9D:9E:A0:E3:43:6A:B7:C0:89:6B:FB:4F:B6:79:F4:DE:5F:E7:C2:3F:32:6C:8F:99:4A"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.chrome.canary",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "DF:A1:FB:23:EF:BF:70:C5:BC:D1:44:3C:5B:EA:B0:4F:3F:2F:F4:36:6E:9A:C1:E3:45:76:39:A2:4C:FC"
}
]
}
}
]
}

View File

@@ -0,0 +1,20 @@
{
"apps": [
{
"type": "android",
"info": {
"package_name": "org.chromium.chrome",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "C6:AD:B8:B8:3C:6D:4C:17:D2:92:AF:DE:56:FD:48:8A:51:D3:16:FF:8F:2C:11:C5:41:02:23:BF:F8:A7:DB:B3"
},
{
"build": "userdebug",
"cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00"
}
]
}
}
]
}

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env python3
import unittest
import os
import json
from validate_json import validate_json, find_duplicates, get_package_names
from unittest.mock import patch
import io
class TestValidateJson(unittest.TestCase):
def setUp(self):
self.valid_file = os.path.join(os.path.dirname(__file__), "fixtures/sample-valid1.json")
self.valid_file2 = os.path.join(os.path.dirname(__file__), "fixtures/sample-valid2.json")
self.invalid_file = os.path.join(os.path.dirname(__file__), "fixtures/sample-invalid.json")
# Suppress stdout
self.stdout_patcher = patch('sys.stdout', new=io.StringIO())
self.stdout_patcher.start()
def tearDown(self):
self.stdout_patcher.stop()
def test_validate_json_valid(self):
"""Test validation of valid JSON file"""
self.assertTrue(validate_json(self.valid_file))
def test_validate_json_invalid(self):
"""Test validation of invalid JSON file"""
self.assertFalse(validate_json(self.invalid_file))
def test_find_duplicates(self):
"""Test when using the same file (should find duplicates)"""
expected_package_names = get_package_names(self.valid_file)
duplicates = find_duplicates(self.valid_file, self.valid_file)
self.assertEqual(len(duplicates), len(expected_package_names))
for package_name in expected_package_names:
self.assertIn(package_name, duplicates)
def test_find_duplicates_returns_empty_list_when_no_duplicates(self):
"""Test when using different files (should not find duplicates)"""
duplicates = find_duplicates(self.valid_file, self.valid_file2)
self.assertEqual(len(duplicates), 0)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,145 @@
#!/usr/bin/env python3
import json
import sys
import os
from typing import List, Dict, Any, Set
def get_package_names(file_path: str) -> Set[str]:
"""
Extracts package names from a JSON file.
Args:
file_path: Path to the JSON file
Returns:
Set of package names
"""
with open(file_path, 'r') as f:
data = json.load(f)
package_names = set()
for app in data["apps"]:
package_names.add(app["info"]["package_name"])
return package_names
def validate_json(file_path: str) -> bool:
"""
Validates if a JSON file is correctly formatted by attempting to deserialize it.
Args:
file_path: Path to the JSON file to validate
Returns:
True if valid, False otherwise
"""
try:
if not os.path.exists(file_path):
print(f"Error: File {file_path} does not exist")
return False
with open(file_path, 'r') as f:
json.load(f)
print(f"✅ JSON file {file_path} is valid")
return True
except json.JSONDecodeError as e:
print(f"❌ Invalid JSON in {file_path}: {str(e)}")
return False
except Exception as e:
print(f"❌ Error validating {file_path}: {str(e)}")
return False
def find_duplicates(file1_path: str, file2_path: str) -> List[str]:
"""
Checks for duplicate package_name entries between two JSON files.
Args:
file1_path: Path to the first JSON file
file2_path: Path to the second JSON file
Returns:
List of duplicate package names, empty list if none found
"""
try:
# Get package names from both files
packages1 = get_package_names(file1_path)
packages2 = get_package_names(file2_path)
# Find duplicates
duplicates = list(packages1.intersection(packages2))
if duplicates:
print(f"❌ Found {len(duplicates)} duplicate package names between {file1_path} and {file2_path}:")
for dup in duplicates:
print(f" - {dup}")
return duplicates
else:
print(f"✅ No duplicate package names found between {file1_path} and {file2_path}")
return []
except Exception as e:
print(f"❌ Error checking duplicates: {str(e)}")
return []
def save_duplicates_to_file(duplicates: List[str], output_file: str) -> None:
"""
Saves the list of duplicates to a file.
Args:
duplicates: List of duplicate package names
output_file: Path to save the list of duplicates
"""
try:
with open(output_file, 'w') as f:
for dup in duplicates:
f.write(f"{dup}\n")
print(f"Duplicates saved to {output_file}")
except Exception as e:
print(f"❌ Error saving duplicates to file: {str(e)}")
def main():
if len(sys.argv) < 2:
print("Usage:")
print(" Validate JSON: python validate_json.py validate <json_file>")
print(" Check duplicates: python validate_json.py duplicates <json_file1> <json_file2> [output_file]")
sys.exit(1)
command = sys.argv[1]
match command:
case "validate":
if len(sys.argv) < 3:
print("Error: Missing JSON file path")
sys.exit(1)
file_path = sys.argv[2]
success = validate_json(file_path)
sys.exit(0 if success else 1)
case "duplicates":
if len(sys.argv) < 4:
print("Error: Missing JSON file paths")
sys.exit(1)
file1_path = sys.argv[2]
file2_path = sys.argv[3]
output_file = sys.argv[4] if len(sys.argv) > 4 else "duplicates.txt"
duplicates = find_duplicates(file1_path, file2_path)
if duplicates:
save_duplicates_to_file(duplicates, output_file)
sys.exit(0)
case _:
print(f"Unknown command: {command}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,98 @@
name: Cron / Sync Google Privileged Browsers List
on:
schedule:
# Run weekly on Monday at 00:00 UTC
- cron: '0 0 * * 1'
workflow_dispatch:
env:
SOURCE_URL: https://www.gstatic.com/gpm-passkeys-privileged-apps/apps.json
GOOGLE_FILE: app/src/main/assets/fido2_privileged_google.json
COMMUNITY_FILE: app/src/main/assets/fido2_privileged_community.json
jobs:
sync-privileged-browsers:
name: Sync Google Privileged Browsers List
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2
- name: Download Google Privileged Browsers List
run: curl -s $SOURCE_URL -o $GOOGLE_FILE
- name: Check for changes
id: check-changes
run: |
if git diff --quiet -- $GOOGLE_FILE; then
echo "👀 No changes detected, skipping..."
echo "has_changes=false" >> $GITHUB_OUTPUT
exit 0
fi
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "👀 Changes detected, validating fido2_privileged_google.json..."
python .github/scripts/validate-json/validate_json.py validate $GOOGLE_FILE
if [ $? -ne 0 ]; then
echo "::error::JSON validation failed for $GOOGLE_FILE"
exit 1
fi
echo "👀 fido2_privileged_google.json is valid, checking for duplicates..."
# Check for duplicates between Google and Community files
python .github/scripts/validate-json/validate_json.py duplicates $GOOGLE_FILE $COMMUNITY_FILE duplicates.txt
if [ -f duplicates.txt ]; then
echo "::warning::Duplicate package names found between Google and Community files."
echo "duplicates_found=true" >> $GITHUB_OUTPUT
else
echo "✅ No duplicate package names found between Google and Community files"
echo "duplicates_found=false" >> $GITHUB_OUTPUT
fi
- name: Create branch and commit
if: steps.check-changes.outputs.has_changes == 'true'
run: |
echo "👀 Committing fido2_privileged_google.json..."
BRANCH_NAME="cron-sync-privileged-browsers/$GITHUB_RUN_NUMBER-sync"
git config user.name "GitHub Actions Bot"
git config user.email "actions@github.com"
git checkout -b $BRANCH_NAME
git add $GOOGLE_FILE
git commit -m "Update Google privileged browsers list"
git push origin $BRANCH_NAME
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
echo "🌱 Branch created: $BRANCH_NAME"
- name: Create Pull Request
if: steps.check-changes.outputs.has_changes == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DUPLICATES_FOUND: ${{ steps.check-changes.outputs.duplicates_found }}
BASE_PR_URL: ${{ github.server_url }}/${{ github.repository }}/pull/
run: |
PR_BODY="Updates the Google privileged browsers list with the latest data from $SOURCE_URL"
if [ "$DUPLICATES_FOUND" = "true" ]; then
PR_BODY="$PR_BODY\n\n> [!WARNING]\n> :suspect: The following package(s) appear in both Google and Community files:"
while IFS= read -r line; do
PR_BODY="$PR_BODY\n> - $line"
done < duplicates.txt
fi
# Use echo -e to interpret escape sequences and pipe to gh pr create
PR_URL=$(echo -e "$PR_BODY" | gh pr create \
--title "Update Google privileged browsers list" \
--body-file - \
--base main \
--head $BRANCH_NAME \
--label "automated-pr" \
--label "t:ci")

View File

@@ -49,6 +49,8 @@ jobs:
uses: github/codeql-action/upload-sarif@d68b2d4edb4189fd2a5366ac14e72027bd4b37dd # v3.28.2
with:
sarif_file: cx_result.sarif
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
quality:
name: Quality scan

View File

@@ -37,6 +37,8 @@ jobs:
uses: github/codeql-action/upload-sarif@b6a472f63d85b9c78a3ac5e89422239fc15e9b3c # v3.28.1
with:
sarif_file: cx_result.sarif
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
quality:
name: Quality scan

View File

@@ -44,6 +44,8 @@ jobs:
uses: github/codeql-action/upload-sarif@b6a472f63d85b9c78a3ac5e89422239fc15e9b3c # v3.28.1
with:
sarif_file: cx_result.sarif
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
quality:
name: Quality scan

7
.gitignore vendored
View File

@@ -28,3 +28,10 @@ user.properties
/app/src/standardBeta/google-services.json
/app/src/standardRelease/google-services.json
/authenticator/src/google-services.json
# Python
.python-version
__pycache__/
# Generated by .github/scripts/validate-json/validate-json.py
duplicates.txt

View File

@@ -9,17 +9,18 @@ GEM
public_suffix (>= 2.0.2, < 7.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.0)
aws-partitions (1.1040.0)
aws-sdk-core (3.216.0)
aws-eventstream (1.3.2)
aws-partitions (1.1067.0)
aws-sdk-core (3.220.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.97.0)
aws-sdk-kms (1.99.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.178.0)
aws-sdk-s3 (1.182.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
@@ -34,7 +35,7 @@ GEM
highline (~> 2.0.0)
date (3.4.1)
declarative (0.0.20)
digest-crc (0.6.5)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
@@ -69,7 +70,7 @@ GEM
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.226.0)
fastlane (2.227.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
@@ -137,12 +138,12 @@ GEM
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.7.1)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.4.0)
google-cloud-errors (1.5.0)
google-cloud-storage (1.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
@@ -160,15 +161,17 @@ GEM
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.8.3)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.9.1)
json (2.10.2)
jwt (2.10.1)
base64
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.15.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.2.1)
nkf (0.2.0)
@@ -182,7 +185,7 @@ GEM
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.4.0)
rexml (3.4.1)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)

248
README.md
View File

@@ -1,232 +1,40 @@
# Bitwarden Android
## Contents
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset=".github/images/android-dark.png">
<source media="(prefers-color-scheme: light)" srcset=".github/images/android-light.png">
<img alt="Bitwarden Android apps screenshots." src=".github/images/android-light.png">
</picture>
</p>
<p align="center">
<a href="https://github.com/bitwarden/android/actions/workflows/build.yml?query=branch:main" target="_blank"><img src="https://github.com/bitwarden/android/actions/workflows/build.yml/badge.svg?branch=main" alt="GitHub Workflow Android CI build on main" /></a>
<a href="https://github.com/bitwarden/android/actions/workflows/test.yml?query=branch:main" target="_blank"><img src="https://github.com/bitwarden/android/actions/workflows/test.yml/badge.svg?branch=main" alt="GitHub Workflow Android Password Manager Test on main" /></a>
<a href="https://gitter.im/bitwarden/Lobby" target="_blank"><img src="https://badges.gitter.im/bitwarden/Lobby.svg" alt="gitter chat" /></a>
</p>
- [Compatibility](#compatibility)
- [Setup](#setup)
- [Dependencies](#dependencies)
---
## Compatibility
# Bitwarden Android Password Manager & Authenticator Apps
- **Minimum SDK**: 29
- **Target SDK**: 35
- **Device Types Supported**: Phone and Tablet
- **Orientations Supported**: Portrait and Landscape
Please refer to the [Contributing Documentation](https://contributing.bitwarden.com/) for setup instructions, recommended tooling, code style tips, and lots of other great information to get you started. Relevant Links:
## Setup
- [Getting Started](https://contributing.bitwarden.com/getting-started/mobile/android/)
- [Code Style](https://contributing.bitwarden.com/contributing/code-style/android-kotlin)
- [Architecture](https://contributing.bitwarden.com/architecture/mobile-clients/android/)
- [Push Notifications Deep Dive](https://contributing.bitwarden.com/architecture/deep-dives/push-notifications/mobile)
## Related projects:
1. Clone the repository:
- [bitwarden/server](https://github.com/bitwarden/server): The core infrastructure backend (API, database, Docker, etc).
- [bitwarden/clients](https://github.com/bitwarden/clients): Non-mobile Bitwarden Clients Applications.
- [bitwarden/directory-connector](https://github.com/bitwarden/directory-connector): A tool for syncing a directory (AD, LDAP, Azure, G Suite, Okta) to an organization.
```sh
$ git clone https://github.com/bitwarden/android
```
# We're Hiring!
2. Create a `user.properties` file in the root directory of the project and add the following properties:
Interested in contributing in a big way? Consider joining our team! We're hiring for many positions. Please take a look at our [Careers page](https://bitwarden.com/careers/) to see what opportunities are [currently open](https://bitwarden.com/careers/#open-positions) as well as what it's like to work at Bitwarden.
- `gitHubToken`: A "classic" Github Personal Access Token (PAT) with the `read:packages` scope (ex: `gitHubToken=gph_xx...xx`). These can be generated by going to the [Github tokens page](https://github.com/settings/tokens). See [the Github Packages user documentation concerning authentication](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-gradle-registry#authenticating-to-github-packages) for more details.
- `localSdk`: A boolean value to determine if the SDK should be loaded from the local maven artifactory (ex: `localSdk=true`). This is particularly useful when developing new SDK capabilities. Review [Linking SDK to clients](https://contributing.bitwarden.com/getting-started/sdk/#linking-the-sdk-to-clients) for more details.
# Contribute
3. Setup the code style formatter:
Code contributions are welcome! Please commit any pull requests against the `main` branch. Learn more about how to contribute by reading the [Contributing Guidelines](https://contributing.bitwarden.com/contributing/). Check out the [Contributing Documentation](https://contributing.bitwarden.com/) for how to get started with your first contribution.
All code must follow the guidelines described in the [Code Style Guidelines document](docs/STYLE_AND_BEST_PRACTICES.md). To aid in adhering to these rules, all contributors should apply `docs/bitwarden-style.xml` as their code style scheme. In IntelliJ / Android Studio:
- Navigate to `Preferences > Editor > Code Style`.
- Hit the `Manage` button next to `Scheme`.
- Select `Import`.
- Find the `bitwarden-style.xml` file in the project's `docs/` directory.
- Import "from" `BitwardenStyle` "to" `BitwardenStyle`.
- Hit `Apply` and `OK` to save the changes and exit Preferences.
Note that in some cases you may need to restart Android Studio for the changes to take effect.
All code should be formatted before submitting a pull request. This can be done manually but it can also be helpful to create a macro with a custom keyboard binding to auto-format when saving. In Android Studio on OS X:
- Select `Edit > Macros > Start Macro Recording`
- Select `Code > Optimize Imports`
- Select `Code > Reformat Code`
- Select `File > Save All`
- Select `Edit > Macros > Stop Macro Recording`
This can then be mapped to a set of keys by navigating to `Android Studio > Preferences` and editing the macro under `Keymap` (ex : shift + command + s).
Please avoid mixing formatting and logical changes in the same commit/PR. When possible, fix any large formatting issues in a separate PR before opening one to make logical changes to the same code. This helps others focus on the meaningful code changes when reviewing the code.
## Dependencies
### Application Dependencies
The following is a list of all third-party dependencies included as part of the application beyond the standard Android SDK.
- **AndroidX Appcompat**
- https://developer.android.com/jetpack/androidx/releases/appcompat
- Purpose: Allows access to new APIs on older API versions.
- License: Apache 2.0
- **AndroidX Autofill**
- https://developer.android.com/jetpack/androidx/releases/autofill
- Purpose: Allows access to tools for building inline autofill UI.
- License: Apache 2.0
- **AndroidX Biometrics**
- https://developer.android.com/jetpack/androidx/releases/biometric
- Purpose: Authenticate with biometrics or device credentials.
- License: Apache 2.0
- **AndroidX Browser**
- https://developer.android.com/jetpack/androidx/releases/browser
- Purpose: Displays webpages with the user's default browser.
- License: Apache 2.0
- **AndroidX CameraX Camera2**
- https://developer.android.com/jetpack/androidx/releases/camera
- Purpose: Display and capture images for barcode scanning.
- License: Apache 2.0
- **AndroidX Compose**
- https://developer.android.com/jetpack/androidx/releases/compose
- Purpose: A Kotlin-based declarative UI framework.
- License: Apache 2.0
- **AndroidX Core SplashScreen**
- https://developer.android.com/jetpack/androidx/releases/core
- Purpose: Backwards compatible SplashScreen API implementation.
- License: Apache 2.0
- **AndroidX Credentials**
- https://developer.android.com/jetpack/androidx/releases/credentials
- Purpose: Unified access to user's credentials.
- License: Apache 2.0
- **AndroidX Lifecycle**
- https://developer.android.com/jetpack/androidx/releases/lifecycle
- Purpose: Lifecycle aware components and tooling.
- License: Apache 2.0
- **AndroidX Room**
- https://developer.android.com/jetpack/androidx/releases/room
- Purpose: A convenient SQLite-based persistence layer for Android.
- License: Apache 2.0
- **AndroidX Security**
- https://developer.android.com/jetpack/androidx/releases/security
- Purpose: Safely manage keys and encrypt files and sharedpreferences.
- License: Apache 2.0
- **AndroidX WorkManager**
- https://developer.android.com/jetpack/androidx/releases/work
- Purpose: The WorkManager is used to schedule deferrable, asynchronous tasks that must be run reliably.
- License: Apache 2.0
- **Dagger Hilt**
- https://github.com/google/dagger
- Purpose: Dependency injection framework.
- License: Apache 2.0
- **Firebase Cloud Messaging**
- https://github.com/firebase/firebase-android-sdk
- Purpose: Allows for push notification support. (**NOTE:** This dependency is not included in builds distributed via F-Droid.)
- License: Apache 2.0
- **Firebase Crashlytics**
- https://github.com/firebase/firebase-android-sdk
- Purpose: SDK for crash and non-fatal error reporting. (**NOTE:** This dependency is not included in builds distributed via F-Droid.)
- License: Apache 2.0
- **Google Play Reviews**
- https://developer.android.com/reference/com/google/android/play/core/release-notes
- Purpose: On standard builds provide an interface to add a review for the password manager application in Google Play.
- License: Apache 2.0
- **Glide**
- https://github.com/bumptech/glide
- Purpose: Image loading and caching.
- License: BSD, part MIT and Apache 2.0
- **kotlinx.collections.immutable**
- https://github.com/Kotlin/kotlinx.collections.immutable
- Purpose: Immutable collection interfaces and implementation prototypes for Kotlin.
- License: Apache 2.0
- **kotlinx.coroutines**
- https://github.com/Kotlin/kotlinx.coroutines
- Purpose: Kotlin coroutines library for asynchronous and reactive code.
- License: Apache 2.0
- **kotlinx.serialization**
- https://github.com/Kotlin/kotlinx.serialization/
- Purpose: JSON serialization library for Kotlin.
- License: Apache 2.0
- **kotlinx.serialization converter**
- https://github.com/square/retrofit/tree/trunk/retrofit-converters/kotlinx-serialization
- Purpose: Converter for Retrofit 2 and kotlinx.serialization.
- License: Apache 2.0
- **OkHttp 3**
- https://github.com/square/okhttp
- Purpose: An HTTP client used by the library to intercept and log traffic.
- License: Apache 2.0
- **Retrofit 2**
- https://github.com/square/retrofit
- Purpose: A networking layer interface.
- License: Apache 2.0
- **Timber**
- https://github.com/JakeWharton/timber
- Purpose: Extensible logging library for Android.
- License: Apache 2.0
- **zxcvbn4j**
- https://github.com/nulab/zxcvbn4j
- Purpose: Password strength estimation.
- License: MIT
- **ZXing**
- https://github.com/zxing/zxing
- Purpose: Barcode scanning and generation.
- License: Apache 2.0
### Development Environment Dependencies
The following is a list of additional third-party dependencies used as part of the local development environment. This includes test-related artifacts as well as tools related to code quality and linting. These are not present in the final packaged application.
- **detekt**
- https://github.com/detekt/detekt
- Purpose: A static code analysis tool for the Kotlin programming language.
- License: Apache 2.0
- **JUnit 5**
- https://github.com/junit-team/junit5
- Purpose: Unit Testing framework for testing application code.
- License: Eclipse Public License 2.0
- **MockK**
- https://github.com/mockk/mockk
- Purpose: Kotlin-friendly mocking library.
- License: Apache 2.0
- **Robolectric**
- https://github.com/robolectric/robolectric
- Purpose: A unit testing framework for code directly depending on the Android framework.
- License: MIT
- **Turbine**
- https://github.com/cashapp/turbine
- Purpose: A small testing library for kotlinx.coroutine's Flow.
- License: Apache 2.0
### CI/CD Dependencies
The following is a list of additional third-party dependencies used as part of the CI/CD workflows. These are not present in the final packaged application.
- **Fastlane**
- https://fastlane.tools/
- Purpose: Automates building, signing, and distributing applications.
- License: MIT
- **Kover**
- https://github.com/Kotlin/kotlinx-kover
- Purpose: Kotlin code coverage toolset.
- License: Apache 2.0
Security audits and feedback are welcome. Please open an issue or email us privately if the report is sensitive in nature. You can read our security policy in the [`SECURITY.md`](SECURITY.md) file.

View File

@@ -68,7 +68,7 @@ android {
buildConfigField(
type = "String",
name = "CI_INFO",
value = "${ciProperties.getOrDefault("ci.info", "\"local\"")}"
value = "${ciProperties.getOrDefault("ci.info", "\"local\"")}",
)
}
@@ -104,7 +104,7 @@ android {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
"proguard-rules.pro",
)
buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "false")
@@ -115,7 +115,7 @@ android {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
"proguard-rules.pro",
)
buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "false")
@@ -180,7 +180,6 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
@Suppress("UnstableApiUsage")
testOptions {
// Required for Robolectric
unitTests.isIncludeAndroidResources = true
@@ -272,6 +271,8 @@ dependencies {
testImplementation(libs.androidx.compose.ui.test)
testImplementation(libs.google.hilt.android.testing)
testImplementation(platform(libs.junit.bom))
testRuntimeOnly(libs.junit.platform.launcher)
testImplementation(libs.junit.junit5)
testImplementation(libs.junit.vintage)
testImplementation(libs.kotlinx.coroutines.test)

View File

@@ -6,6 +6,11 @@ package com.x8bit.bitwarden
const val LEGACY_ACCESSIBILITY_SERVICE_NAME: String =
"com.x8bit.bitwarden.Accessibility.AccessibilityService"
/**
* The short form legacy name for the accessibility service.
*/
const val LEGACY_SHORT_ACCESSIBILITY_SERVICE_NAME: String = ".Accessibility.AccessibilityService"
/**
* The legacy name for the autofill service.
*/

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.view.MotionEvent
@@ -15,6 +16,7 @@ import androidx.compose.runtime.remember
import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityCompletionManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillActivityManager
@@ -24,16 +26,20 @@ import com.x8bit.bitwarden.data.platform.manager.util.ObserveScreenDataEffect
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.composition.LocalManagerProvider
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.debugMenuDestination
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager.DebugMenuLaunchManager
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.navigateToDebugMenuScreen
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavScreen
import com.x8bit.bitwarden.ui.platform.feature.rootnav.ROOT_ROUTE
import com.x8bit.bitwarden.ui.platform.feature.rootnav.rootNavDestination
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.platform.util.appLanguage
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
/**
* Primary entry point for the application.
*/
@Suppress("TooManyFunctions")
@OmitFromCoverage
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@@ -69,13 +75,9 @@ class MainActivity : AppCompatActivity() {
)
}
// Within the app the language and theme will change dynamically and will be managed by the
// Within the app the theme will change dynamically and will be managed by the
// OS, but we need to ensure we properly set the values when upgrading from older versions
// that handle this differently or when the activity restarts.
settingsRepository.appLanguage.localeName?.let { localeName ->
val localeList = LocaleListCompat.forLanguageTags(localeName)
AppCompatDelegate.setApplicationLocales(localeList)
}
AppCompatDelegate.setDefaultNightMode(settingsRepository.appTheme.osValue)
setContent {
val state by mainViewModel.stateFlow.collectAsStateWithLifecycle()
@@ -111,7 +113,7 @@ class MainActivity : AppCompatActivity() {
}
}
updateScreenCapture(isScreenCaptureAllowed = state.isScreenCaptureAllowed)
LocalManagerProvider {
LocalManagerProvider(featureFlagsState = state.featureFlagsState) {
ObserveScreenDataEffect(
onDataUpdate = remember(mainViewModel) {
{
@@ -122,10 +124,19 @@ class MainActivity : AppCompatActivity() {
},
)
BitwardenTheme(theme = state.theme) {
RootNavScreen(
onSplashScreenRemoved = { shouldShowSplashScreen = false },
NavHost(
navController = navController,
)
startDestination = ROOT_ROUTE,
) {
// Nothing else should end up at this top level, we just want the ability
// to have the debug menu appear on top of the rest of the app without
// interacting with the state-based navigation used by the RootNavScreen.
rootNavDestination { shouldShowSplashScreen = false }
debugMenuDestination(
onNavigateBack = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
}
}
}
}
@@ -140,6 +151,31 @@ class MainActivity : AppCompatActivity() {
)
}
override fun onResume() {
super.onResume()
// When the app resumes check for any app specific language which may have been
// set via the device settings. Similar to the theme setting in onCreate this
// ensures we properly set the values when upgrading from older versions
// that handle this differently or when the activity restarts.
val appSpecificLanguage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val locales: LocaleListCompat = AppCompatDelegate.getApplicationLocales()
if (locales.isEmpty) {
// App is using the system language
null
} else {
// App has specific language settings
locales.get(0)?.appLanguage
}
} else {
// For older versions, use what ever language is available from the repository.
settingsRepository.appLanguage
}
appSpecificLanguage?.let {
mainViewModel.trySendAction(MainAction.AppSpecificLanguageUpdate(it))
}
}
override fun onStop() {
super.onStop()
// In some scenarios on an emulator the Activity can leak when recreated

View File

@@ -19,10 +19,12 @@ import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
@@ -32,8 +34,10 @@ import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.model.FeatureFlagsState
import com.x8bit.bitwarden.ui.platform.util.isAccountSecurityShortcut
import com.x8bit.bitwarden.ui.platform.util.isMyVaultShortcut
import com.x8bit.bitwarden.ui.platform.util.isPasswordGeneratorShortcut
@@ -54,6 +58,7 @@ import java.time.Clock
import javax.inject.Inject
private const val SPECIAL_CIRCUMSTANCE_KEY = "special-circumstance"
private const val ANIMATION_REFRESH_DELAY = 500L
/**
* A view model that helps launch actions for the [MainActivity].
@@ -63,12 +68,13 @@ private const val SPECIAL_CIRCUMSTANCE_KEY = "special-circumstance"
class MainViewModel @Inject constructor(
accessibilitySelectionManager: AccessibilitySelectionManager,
autofillSelectionManager: AutofillSelectionManager,
featureFlagManager: FeatureFlagManager,
private val addTotpItemFromAuthenticatorManager: AddTotpItemFromAuthenticatorManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val garbageCollectionManager: GarbageCollectionManager,
private val fido2CredentialManager: Fido2CredentialManager,
private val intentManager: IntentManager,
settingsRepository: SettingsRepository,
private val settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository,
private val authRepository: AuthRepository,
private val environmentRepository: EnvironmentRepository,
@@ -79,6 +85,9 @@ class MainViewModel @Inject constructor(
initialState = MainState(
theme = settingsRepository.appTheme,
isScreenCaptureAllowed = settingsRepository.isScreenCaptureAllowed,
isErrorReportingDialogEnabled = featureFlagManager.getFeatureFlag(
key = FlagKey.MobileErrorReporting,
),
),
) {
private var specialCircumstance: SpecialCircumstance?
@@ -96,6 +105,12 @@ class MainViewModel @Inject constructor(
.onEach { specialCircumstance = it }
.launchIn(viewModelScope)
featureFlagManager
.getFeatureFlagFlow(key = FlagKey.MobileErrorReporting)
.map { MainAction.Internal.OnMobileErrorReportingReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
accessibilitySelectionManager
.accessibilitySelectionFlow
.map { MainAction.Internal.AccessibilitySelectionReceive(it) }
@@ -134,8 +149,7 @@ class MainViewModel @Inject constructor(
// Switching between account states often involves some kind of animation (ex:
// account switcher) that we might want to give time to finish before triggering
// a refresh.
@Suppress("MagicNumber")
delay(500)
delay(ANIMATION_REFRESH_DELAY)
trySendAction(MainAction.Internal.CurrentUserStateChange)
}
.launchIn(viewModelScope)
@@ -147,8 +161,7 @@ class MainViewModel @Inject constructor(
is VaultStateEvent.Locked -> {
// Similar to account switching, triggering this action too soon can
// interfere with animations or navigation logic, so we will delay slightly.
@Suppress("MagicNumber")
delay(500)
delay(ANIMATION_REFRESH_DELAY)
trySendAction(MainAction.Internal.VaultUnlockStateChange)
}
@@ -172,6 +185,17 @@ class MainViewModel @Inject constructor(
}
override fun handleAction(action: MainAction) {
when (action) {
is MainAction.ReceiveFirstIntent -> handleFirstIntentReceived(action)
is MainAction.ReceiveNewIntent -> handleNewIntentReceived(action)
MainAction.OpenDebugMenu -> handleOpenDebugMenu()
is MainAction.ResumeScreenDataReceived -> handleAppResumeDataUpdated(action)
is MainAction.AppSpecificLanguageUpdate -> handleAppSpecificLanguageUpdate(action)
is MainAction.Internal -> handleInternalAction(action)
}
}
private fun handleInternalAction(action: MainAction.Internal) {
when (action) {
is MainAction.Internal.AccessibilitySelectionReceive -> {
handleAccessibilitySelectionReceive(action)
@@ -185,13 +209,24 @@ class MainViewModel @Inject constructor(
is MainAction.Internal.ScreenCaptureUpdate -> handleScreenCaptureUpdate(action)
is MainAction.Internal.ThemeUpdate -> handleAppThemeUpdated(action)
is MainAction.Internal.VaultUnlockStateChange -> handleVaultUnlockStateChange()
is MainAction.ReceiveFirstIntent -> handleFirstIntentReceived(action)
is MainAction.ReceiveNewIntent -> handleNewIntentReceived(action)
MainAction.OpenDebugMenu -> handleOpenDebugMenu()
is MainAction.ResumeScreenDataReceived -> handleAppResumeDataUpdated(action)
is MainAction.Internal.OnMobileErrorReportingReceive -> {
handleOnMobileErrorReportingReceive(action)
}
}
}
private fun handleOnMobileErrorReportingReceive(
action: MainAction.Internal.OnMobileErrorReportingReceive,
) {
mutableStateFlow.update {
it.copy(isErrorReportingDialogEnabled = action.isErrorReportingEnabled)
}
}
private fun handleAppSpecificLanguageUpdate(action: MainAction.AppSpecificLanguageUpdate) {
settingsRepository.appLanguage = action.appLanguage
}
private fun handleAppResumeDataUpdated(action: MainAction.ResumeScreenDataReceived) {
when (val data = action.screenResumeData) {
null -> appResumeManager.clearResumeScreen()
@@ -445,7 +480,16 @@ class MainViewModel @Inject constructor(
data class MainState(
val theme: AppTheme,
val isScreenCaptureAllowed: Boolean,
) : Parcelable
private val isErrorReportingDialogEnabled: Boolean,
) : Parcelable {
/**
* Contains all feature flags that are available to the UI.
*/
val featureFlagsState: FeatureFlagsState
get() = FeatureFlagsState(
isErrorReportingDialogEnabled = isErrorReportingDialogEnabled,
)
}
/**
* Models actions for the [MainActivity].
@@ -471,6 +515,12 @@ sealed class MainAction {
*/
data class ResumeScreenDataReceived(val screenResumeData: AppResumeScreenData?) : MainAction()
/**
* Receive if there is an app specific locale selection made by user
* in the device's settings.
*/
data class AppSpecificLanguageUpdate(val appLanguage: AppLanguage) : MainAction()
/**
* Actions for internal use by the ViewModel.
*/
@@ -483,6 +533,13 @@ sealed class MainAction {
val cipherView: CipherView,
) : Internal()
/**
* Indicates the Mobile Error Reporting feature flag has been updated.
*/
data class OnMobileErrorReportingReceive(
val isErrorReportingEnabled: Boolean,
) : Internal()
/**
* Indicates the user has manually selected the given [cipherView] for autofill.
*/

View File

@@ -23,6 +23,15 @@ sealed class DeleteAccountResponseJson {
@Serializable
data class Invalid(
@SerialName("validationErrors")
val validationErrors: Map<String, List<String?>>?,
) : DeleteAccountResponseJson()
private val validationErrors: Map<String, List<String?>>?,
) : DeleteAccountResponseJson() {
/**
* A human readable error message.
*/
val message: String?
get() = validationErrors
?.values
?.firstOrNull()
?.firstOrNull()
}
}

View File

@@ -16,6 +16,7 @@ import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestsUpdatesResult
import com.x8bit.bitwarden.data.auth.manager.model.CreateAuthRequestResult
import com.x8bit.bitwarden.data.auth.manager.util.isSso
import com.x8bit.bitwarden.data.auth.manager.util.toAuthRequestTypeJson
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.platform.util.flatMap
@@ -51,7 +52,9 @@ class AuthRequestManagerImpl(
override fun getAuthRequestsWithUpdates(): Flow<AuthRequestsUpdatesResult> = flow {
while (currentCoroutineContext().isActive) {
when (val result = getAuthRequests()) {
AuthRequestsResult.Error -> emit(AuthRequestsUpdatesResult.Error)
is AuthRequestsResult.Error -> {
emit(AuthRequestsUpdatesResult.Error(error = result.error))
}
is AuthRequestsResult.Success -> {
emit(AuthRequestsUpdatesResult.Update(authRequests = result.authRequests))
@@ -70,9 +73,8 @@ class AuthRequestManagerImpl(
email = email,
authRequestType = authRequestType.toAuthRequestTypeJson(),
)
.getOrNull()
?: run {
emit(CreateAuthRequestResult.Error)
.getOrElse {
emit(CreateAuthRequestResult.Error(error = it))
return@flow
}
var authRequest = initialResult.authRequest
@@ -103,7 +105,7 @@ class AuthRequestManagerImpl(
)
}
.fold(
onFailure = { emit(CreateAuthRequestResult.Error) },
onFailure = { emit(CreateAuthRequestResult.Error(error = it)) },
onSuccess = { updateAuthRequest ->
when {
updateAuthRequest.requestApproved -> {
@@ -182,7 +184,7 @@ class AuthRequestManagerImpl(
)
}
.fold(
onFailure = { emit(AuthRequestUpdatesResult.Error) },
onFailure = { emit(AuthRequestUpdatesResult.Error(error = it)) },
onSuccess = { updateAuthRequest ->
when {
updateAuthRequest.requestApproved -> {
@@ -218,13 +220,18 @@ class AuthRequestManagerImpl(
fingerprint: String,
): Flow<AuthRequestUpdatesResult> = getAuthRequest {
when (val authRequestsResult = getAuthRequests()) {
AuthRequestsResult.Error -> AuthRequestUpdatesResult.Error
is AuthRequestsResult.Error -> {
AuthRequestUpdatesResult.Error(error = authRequestsResult.error)
}
is AuthRequestsResult.Success -> {
authRequestsResult
.authRequests
.firstOrNull { it.fingerprint == fingerprint }
?.let { AuthRequestUpdatesResult.Update(it) }
?: AuthRequestUpdatesResult.Error
?: AuthRequestUpdatesResult.Error(
error = IllegalStateException("Could not find the auth request."),
)
}
}
}
@@ -234,30 +241,28 @@ class AuthRequestManagerImpl(
): Flow<AuthRequestUpdatesResult> = getAuthRequest {
authRequestsService
.getAuthRequest(requestId)
.map { response ->
getFingerprintPhrase(response.publicKey).getOrNull()?.let { fingerprint ->
AuthRequest(
id = response.id,
publicKey = response.publicKey,
platform = response.platform,
ipAddress = response.ipAddress,
key = response.key,
masterPasswordHash = response.masterPasswordHash,
creationDate = response.creationDate,
responseDate = response.responseDate,
requestApproved = response.requestApproved ?: false,
originUrl = response.originUrl,
fingerprint = fingerprint,
)
}
.mapCatching { response ->
getFingerprintPhrase(response.publicKey)
.getOrThrow()
.let { fingerprint ->
AuthRequest(
id = response.id,
publicKey = response.publicKey,
platform = response.platform,
ipAddress = response.ipAddress,
key = response.key,
masterPasswordHash = response.masterPasswordHash,
creationDate = response.creationDate,
responseDate = response.responseDate,
requestApproved = response.requestApproved ?: false,
originUrl = response.originUrl,
fingerprint = fingerprint,
)
}
}
.fold(
onFailure = { AuthRequestUpdatesResult.Error },
onSuccess = { authRequest ->
authRequest
?.let { AuthRequestUpdatesResult.Update(it) }
?: AuthRequestUpdatesResult.Error
},
onFailure = { AuthRequestUpdatesResult.Error(error = it) },
onSuccess = { AuthRequestUpdatesResult.Update(authRequest = it) },
)
}
@@ -309,7 +314,7 @@ class AuthRequestManagerImpl(
}
}
.fold(
onFailure = { AuthRequestsResult.Error },
onFailure = { AuthRequestsResult.Error(error = it) },
onSuccess = { AuthRequestsResult.Success(authRequests = it) },
)
@@ -319,7 +324,7 @@ class AuthRequestManagerImpl(
publicKey: String,
isApproved: Boolean,
): AuthRequestResult {
val userId = activeUserId ?: return AuthRequestResult.Error
val userId = activeUserId ?: return AuthRequestResult.Error(error = NoActiveUserException())
return vaultSdkSource
.getAuthRequestKey(
publicKey = publicKey,
@@ -350,7 +355,7 @@ class AuthRequestManagerImpl(
)
}
.fold(
onFailure = { AuthRequestResult.Error },
onFailure = { AuthRequestResult.Error(error = it) },
onSuccess = { AuthRequestResult.Success(authRequest = it) },
)
}
@@ -462,7 +467,7 @@ class AuthRequestManagerImpl(
publicKey: String,
): Result<String> {
val profile = authDiskSource.userState?.activeAccount?.profile
?: return IllegalStateException("No active account").asFailure()
?: return NoActiveUserException().asFailure()
return authSdkSource.getUserFingerprint(
email = profile.email,
publicKey = publicKey,

View File

@@ -14,5 +14,7 @@ sealed class AuthRequestResult {
/**
* There was an error getting the user's auth requests.
*/
data object Error : AuthRequestResult()
data class Error(
val error: Throwable,
) : AuthRequestResult()
}

View File

@@ -19,7 +19,9 @@ sealed class AuthRequestUpdatesResult {
/**
* There was an error getting the user's auth requests.
*/
data object Error : AuthRequestUpdatesResult()
data class Error(
val error: Throwable,
) : AuthRequestUpdatesResult()
/**
* The auth request has been declined.

View File

@@ -14,5 +14,7 @@ sealed class AuthRequestsResult {
/**
* There was an error getting the user's auth requests.
*/
data object Error : AuthRequestsResult()
data class Error(
val error: Throwable,
) : AuthRequestsResult()
}

View File

@@ -14,5 +14,7 @@ sealed class AuthRequestsUpdatesResult {
/**
* There was an error getting the user's auth requests.
*/
data object Error : AuthRequestsUpdatesResult()
data class Error(
val error: Throwable,
) : AuthRequestsUpdatesResult()
}

View File

@@ -23,7 +23,9 @@ sealed class CreateAuthRequestResult {
/**
* There was a generic error getting the user's auth requests.
*/
data object Error : CreateAuthRequestResult()
data class Error(
val error: Throwable,
) : CreateAuthRequestResult()
/**
* The auth request has been declined.

View File

@@ -99,6 +99,8 @@ import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.datasource.disk.ConfigDiskSource
import com.x8bit.bitwarden.data.platform.datasource.network.util.isSslHandShakeError
import com.x8bit.bitwarden.data.platform.error.MissingPropertyException
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
@@ -467,7 +469,7 @@ class AuthRepositoryImpl(
masterPassword: String,
): DeleteAccountResult {
val profile = authDiskSource.userState?.activeAccount?.profile
?: return DeleteAccountResult.Error(message = null)
?: return DeleteAccountResult.Error(message = null, error = NoActiveUserException())
mutableHasPendingAccountDeletionStateFlow.value = true
return authSdkSource
.hashPassword(
@@ -501,18 +503,13 @@ class AuthRepositoryImpl(
fold(
onFailure = {
clearPendingAccountDeletion()
DeleteAccountResult.Error(message = null)
DeleteAccountResult.Error(error = it, message = null)
},
onSuccess = { response ->
when (response) {
is DeleteAccountResponseJson.Invalid -> {
clearPendingAccountDeletion()
DeleteAccountResult.Error(
message = response.validationErrors
?.values
?.firstOrNull()
?.firstOrNull(),
)
DeleteAccountResult.Error(message = response.message, error = null)
}
DeleteAccountResponseJson.Success -> {
@@ -524,8 +521,10 @@ class AuthRepositoryImpl(
)
override suspend fun createNewSsoUser(): NewSsoUserResult {
val account = authDiskSource.userState?.activeAccount ?: return NewSsoUserResult.Failure
val orgIdentifier = rememberedOrgIdentifier ?: return NewSsoUserResult.Failure
val account = authDiskSource.userState?.activeAccount
?: return NewSsoUserResult.Failure(error = NoActiveUserException())
val orgIdentifier = rememberedOrgIdentifier
?: return NewSsoUserResult.Failure(error = MissingPropertyException("OrgIdentifier"))
val userId = account.profile.userId
return organizationService
.getOrganizationAutoEnrollStatus(orgIdentifier)
@@ -577,7 +576,7 @@ class AuthRepositoryImpl(
}
.fold(
onSuccess = { NewSsoUserResult.Success },
onFailure = { NewSsoUserResult.Failure },
onFailure = { NewSsoUserResult.Failure(error = it) },
)
}
@@ -586,10 +585,13 @@ class AuthRepositoryImpl(
asymmetricalKey: String,
): LoginResult {
val profile = authDiskSource.userState?.activeAccount?.profile
?: return LoginResult.Error(errorMessage = null)
?: return LoginResult.Error(errorMessage = null, error = NoActiveUserException())
val userId = profile.userId
val privateKey = authDiskSource.getPrivateKey(userId = userId)
?: return LoginResult.Error(errorMessage = null)
?: return LoginResult.Error(
errorMessage = null,
error = MissingPropertyException("Private Key"),
)
checkForVaultUnlockError(
onVaultUnlockError = { error ->
@@ -639,7 +641,7 @@ class AuthRepositoryImpl(
onFailure = { throwable ->
when {
throwable.isSslHandShakeError() -> LoginResult.CertificateError
else -> LoginResult.Error(errorMessage = null)
else -> LoginResult.Error(errorMessage = null, error = throwable)
}
},
onSuccess = { it },
@@ -688,7 +690,10 @@ class AuthRepositoryImpl(
orgIdentifier = orgIdentifier,
)
}
?: LoginResult.Error(errorMessage = null)
?: LoginResult.Error(
errorMessage = null,
error = MissingPropertyException("Identity Token Auth Model"),
)
override suspend fun login(
email: String,
@@ -708,7 +713,10 @@ class AuthRepositoryImpl(
orgIdentifier = orgIdentifier,
)
}
?: LoginResult.Error(errorMessage = null)
?: LoginResult.Error(
errorMessage = null,
error = MissingPropertyException("Identity Token Auth Model"),
)
override suspend fun login(
email: String,
@@ -767,7 +775,7 @@ class AuthRepositoryImpl(
override suspend fun requestOneTimePasscode(): RequestOtpResult =
accountsService.requestOneTimePasscode()
.fold(
onFailure = { RequestOtpResult.Error(it.message) },
onFailure = { RequestOtpResult.Error(message = it.message, error = it) },
onSuccess = { RequestOtpResult.Success },
)
@@ -777,7 +785,7 @@ class AuthRepositoryImpl(
passcode = oneTimePasscode,
)
.fold(
onFailure = { VerifyOtpResult.NotVerified(it.message) },
onFailure = { VerifyOtpResult.NotVerified(errorMessage = it.message, error = it) },
onSuccess = { VerifyOtpResult.Verified },
)
@@ -785,21 +793,27 @@ class AuthRepositoryImpl(
resendEmailRequestJson
?.let { jsonRequest ->
accountsService.resendVerificationCodeEmail(body = jsonRequest).fold(
onFailure = { ResendEmailResult.Error(message = it.message) },
onFailure = { ResendEmailResult.Error(message = it.message, error = it) },
onSuccess = { ResendEmailResult.Success },
)
}
?: ResendEmailResult.Error(message = null)
?: ResendEmailResult.Error(
message = null,
error = MissingPropertyException("Resend Email Request"),
)
override suspend fun resendNewDeviceOtp(): ResendEmailResult =
resendNewDeviceOtpRequestJson
?.let { jsonRequest ->
accountsService.resendNewDeviceOtp(body = jsonRequest).fold(
onFailure = { ResendEmailResult.Error(message = it.message) },
onFailure = { ResendEmailResult.Error(message = it.message, error = it) },
onSuccess = { ResendEmailResult.Success },
)
}
?: ResendEmailResult.Error(message = null)
?: ResendEmailResult.Error(
message = null,
error = MissingPropertyException("Resend New Device OTP Request"),
)
override fun switchAccount(userId: String): SwitchAccountResult {
val currentUserState = authDiskSource.userState ?: return SwitchAccountResult.NoChange
@@ -901,7 +915,10 @@ class AuthRepositoryImpl(
is RegisterResponseJson.CaptchaRequired -> {
it.validationErrors.captchaKeys.firstOrNull()
?.let { key -> RegisterResult.CaptchaRequired(captchaId = key) }
?: RegisterResult.Error(errorMessage = null)
?: RegisterResult.Error(
errorMessage = null,
error = MissingPropertyException("Captcha ID"),
)
}
is RegisterResponseJson.Success -> {
@@ -910,11 +927,11 @@ class AuthRepositoryImpl(
}
is RegisterResponseJson.Invalid -> {
RegisterResult.Error(errorMessage = it.message)
RegisterResult.Error(errorMessage = it.message, error = null)
}
}
},
onFailure = { RegisterResult.Error(errorMessage = null) },
onFailure = { RegisterResult.Error(errorMessage = null, error = it) },
)
}
@@ -922,11 +939,15 @@ class AuthRepositoryImpl(
return accountsService.requestPasswordHint(email).fold(
onSuccess = {
when (it) {
is PasswordHintResponseJson.Error -> PasswordHintResult.Error(it.errorMessage)
is PasswordHintResponseJson.Error -> PasswordHintResult.Error(
message = it.errorMessage,
error = null,
)
PasswordHintResponseJson.Success -> PasswordHintResult.Success
}
},
onFailure = { PasswordHintResult.Error(null) },
onFailure = { PasswordHintResult.Error(message = null, error = it) },
)
}
@@ -934,12 +955,12 @@ class AuthRepositoryImpl(
val activeAccount = authDiskSource
.userState
?.activeAccount
?: return RemovePasswordResult.Error
?: return RemovePasswordResult.Error(error = NoActiveUserException())
val profile = activeAccount.profile
val userId = profile.userId
val userKey = authDiskSource
.getUserKey(userId = userId)
?: return RemovePasswordResult.Error
?: return RemovePasswordResult.Error(error = MissingPropertyException("User Key"))
val keyConnectorUrl = organizations
.find {
it.shouldUseKeyConnector &&
@@ -947,7 +968,9 @@ class AuthRepositoryImpl(
it.type != OrganizationType.ADMIN
}
?.keyConnectorUrl
?: return RemovePasswordResult.Error
?: return RemovePasswordResult.Error(
error = MissingPropertyException("Key Connector URL"),
)
return keyConnectorManager
.migrateExistingUserToKeyConnector(
userId = userId,
@@ -965,7 +988,7 @@ class AuthRepositoryImpl(
settingsRepository.setDefaultsIfNecessary(userId = userId)
}
.fold(
onFailure = { RemovePasswordResult.Error },
onFailure = { RemovePasswordResult.Error(error = it) },
onSuccess = { RemovePasswordResult.Success },
)
}
@@ -978,7 +1001,7 @@ class AuthRepositoryImpl(
val activeAccount = authDiskSource
.userState
?.activeAccount
?: return ResetPasswordResult.Error
?: return ResetPasswordResult.Error(error = NoActiveUserException())
val currentPasswordHash = currentPassword?.let { password ->
authSdkSource
.hashPassword(
@@ -988,7 +1011,7 @@ class AuthRepositoryImpl(
purpose = HashPurpose.SERVER_AUTHORIZATION,
)
.fold(
onFailure = { return ResetPasswordResult.Error },
onFailure = { return ResetPasswordResult.Error(error = it) },
onSuccess = { it },
)
}
@@ -1034,7 +1057,7 @@ class AuthRepositoryImpl(
// Return the success.
ResetPasswordResult.Success
},
onFailure = { ResetPasswordResult.Error },
onFailure = { ResetPasswordResult.Error(error = it) },
)
}
@@ -1047,7 +1070,7 @@ class AuthRepositoryImpl(
val activeAccount = authDiskSource
.userState
?.activeAccount
?: return SetPasswordResult.Error
?: return SetPasswordResult.Error(error = NoActiveUserException())
val userId = activeAccount.profile.userId
// Update the saved master password hash.
@@ -1058,7 +1081,7 @@ class AuthRepositoryImpl(
kdf = activeAccount.profile.toSdkParams(),
purpose = HashPurpose.SERVER_AUTHORIZATION,
)
.getOrElse { return@setPassword SetPasswordResult.Error }
.getOrElse { return@setPassword SetPasswordResult.Error(error = it) }
return when (activeAccount.profile.forcePasswordResetReason) {
ForcePasswordResetReason.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION -> {
@@ -1108,7 +1131,7 @@ class AuthRepositoryImpl(
}
}
.flatMap {
when (vaultRepository.unlockVaultWithMasterPassword(password)) {
when (val result = vaultRepository.unlockVaultWithMasterPassword(password)) {
is VaultUnlockResult.Success -> {
enrollUserInPasswordReset(
userId = userId,
@@ -1117,12 +1140,9 @@ class AuthRepositoryImpl(
)
}
is VaultUnlockResult.AuthenticationError,
VaultUnlockResult.BiometricDecodingError,
VaultUnlockResult.InvalidStateError,
VaultUnlockResult.GenericError,
-> {
IllegalStateException("Failed to unlock vault").asFailure()
is VaultUnlockError -> {
(result.error ?: IllegalStateException("Failed to unlock vault"))
.asFailure()
}
}
}
@@ -1132,7 +1152,7 @@ class AuthRepositoryImpl(
this.organizationIdentifier = null
}
.fold(
onFailure = { SetPasswordResult.Error },
onFailure = { SetPasswordResult.Error(error = it) },
onSuccess = { SetPasswordResult.Success },
)
}
@@ -1167,7 +1187,7 @@ class AuthRepositoryImpl(
verifiedDate = it.verifiedDate,
)
},
onFailure = { OrganizationDomainSsoDetailsResult.Failure },
onFailure = { OrganizationDomainSsoDetailsResult.Failure(error = it) },
)
override suspend fun getVerifiedOrganizationDomainSsoDetails(
@@ -1182,7 +1202,7 @@ class AuthRepositoryImpl(
verifiedOrganizationDomainSsoDetails = it.verifiedOrganizationDomainSsoDetails,
)
},
onFailure = { VerifiedOrganizationDomainSsoDetailsResult.Failure },
onFailure = { VerifiedOrganizationDomainSsoDetailsResult.Failure(error = it) },
)
override suspend fun prevalidateSso(
@@ -1195,19 +1215,19 @@ class AuthRepositoryImpl(
onSuccess = {
when (it) {
is PrevalidateSsoResponseJson.Error -> {
PrevalidateSsoResult.Failure(message = it.message)
PrevalidateSsoResult.Failure(message = it.message, error = null)
}
is PrevalidateSsoResponseJson.Success -> {
if (it.token.isNullOrBlank()) {
PrevalidateSsoResult.Failure()
PrevalidateSsoResult.Failure(error = MissingPropertyException("Token"))
} else {
PrevalidateSsoResult.Success(token = it.token)
}
}
}
},
onFailure = { PrevalidateSsoResult.Failure() },
onFailure = { PrevalidateSsoResult.Failure(error = it) },
)
override fun setSsoCallbackResult(result: SsoCallbackResult) {
@@ -1221,15 +1241,15 @@ class AuthRepositoryImpl(
deviceId = authDiskSource.uniqueAppId,
)
.fold(
onFailure = { KnownDeviceResult.Error },
onSuccess = { KnownDeviceResult.Success(it) },
onFailure = { KnownDeviceResult.Error(error = it) },
onSuccess = { KnownDeviceResult.Success(isKnownDevice = it) },
)
override suspend fun getPasswordBreachCount(password: String): BreachCountResult =
haveIBeenPwnedService
.getPasswordBreachCount(password)
.fold(
onFailure = { BreachCountResult.Error },
onFailure = { BreachCountResult.Error(error = it) },
onSuccess = { BreachCountResult.Success(it) },
)
@@ -1249,11 +1269,11 @@ class AuthRepositoryImpl(
)
.fold(
onSuccess = { PasswordStrengthResult.Success(passwordStrength = it) },
onFailure = { PasswordStrengthResult.Error },
onFailure = { PasswordStrengthResult.Error(error = it) },
)
override suspend fun validatePassword(password: String): ValidatePasswordResult {
val userId = activeUserId ?: return ValidatePasswordResult.Error
val userId = activeUserId ?: return ValidatePasswordResult.Error(NoActiveUserException())
return authDiskSource
.getMasterPasswordHash(userId = userId)
?.let { masterPasswordHash ->
@@ -1265,13 +1285,13 @@ class AuthRepositoryImpl(
)
.fold(
onSuccess = { ValidatePasswordResult.Success(isValid = it) },
onFailure = { ValidatePasswordResult.Error },
onFailure = { ValidatePasswordResult.Error(error = it) },
)
}
?: run {
val encryptedKey = authDiskSource
.getUserKey(userId)
?: return ValidatePasswordResult.Error
?: return ValidatePasswordResult.Error(MissingPropertyException("UserKey"))
vaultSdkSource
.validatePasswordUserKey(
userId = userId,
@@ -1301,10 +1321,12 @@ class AuthRepositoryImpl(
.userState
?.activeAccount
?.profile
?: return ValidatePinResult.Error
?: return ValidatePinResult.Error(error = NoActiveUserException())
val pinProtectedUserKey = authDiskSource
.getPinProtectedUserKey(userId = activeAccount.userId)
?: return ValidatePinResult.Error
?: return ValidatePinResult.Error(
error = MissingPropertyException("Pin Protected User Key"),
)
return vaultSdkSource
.validatePin(
userId = activeAccount.userId,
@@ -1313,7 +1335,7 @@ class AuthRepositoryImpl(
)
.fold(
onSuccess = { ValidatePinResult.Success(isValid = it) },
onFailure = { ValidatePinResult.Error },
onFailure = { ValidatePinResult.Error(error = it) },
)
}
@@ -1339,7 +1361,10 @@ class AuthRepositoryImpl(
onSuccess = {
when (it) {
is SendVerificationEmailResponseJson.Invalid -> {
SendVerificationEmailResult.Error(it.message)
SendVerificationEmailResult.Error(
errorMessage = it.message,
error = null,
)
}
is SendVerificationEmailResponseJson.Success -> {
@@ -1347,9 +1372,7 @@ class AuthRepositoryImpl(
}
}
},
onFailure = {
SendVerificationEmailResult.Error(null)
},
onFailure = { SendVerificationEmailResult.Error(errorMessage = null, error = it) },
)
override suspend fun validateEmailToken(email: String, token: String): EmailTokenResult {
@@ -1365,15 +1388,13 @@ class AuthRepositoryImpl(
when (val json = it) {
VerifyEmailTokenResponseJson.Valid -> EmailTokenResult.Success
is VerifyEmailTokenResponseJson.Invalid -> {
EmailTokenResult.Error(json.message)
EmailTokenResult.Error(message = json.message, error = null)
}
VerifyEmailTokenResponseJson.TokenExpired -> EmailTokenResult.Expired
}
},
onFailure = {
EmailTokenResult.Error(message = null)
},
onFailure = { EmailTokenResult.Error(message = null, error = it) },
)
}
@@ -1645,7 +1666,10 @@ class AuthRepositoryImpl(
LoginResult.UnofficialServerError
}
else -> LoginResult.Error(errorMessage = null)
else -> LoginResult.Error(
errorMessage = null,
error = throwable,
)
}
},
onSuccess = { loginResponse ->
@@ -1681,6 +1705,7 @@ class AuthRepositoryImpl(
is GetTokenResponseJson.Invalid.InvalidType.GenericInvalid -> {
LoginResult.Error(
errorMessage = loginResponse.errorMessage,
error = null,
)
}
}
@@ -1883,7 +1908,7 @@ class AuthRepositoryImpl(
}
.fold(
// If the request failed, we want to abort the login process
onFailure = { VaultUnlockResult.GenericError },
onFailure = { VaultUnlockResult.GenericError(error = it) },
onSuccess = { it },
)
} else {
@@ -1923,7 +1948,7 @@ class AuthRepositoryImpl(
}
.fold(
// If the request failed, we want to abort the login process
onFailure = { VaultUnlockResult.GenericError },
onFailure = { VaultUnlockResult.GenericError(error = it) },
onSuccess = { it },
)
}

View File

@@ -12,5 +12,8 @@ sealed class BreachCountResult {
/**
* There was an error determining if the password has been breached.
*/
data object Error : BreachCountResult()
data class Error(
val error: Throwable,
val message: String? = null,
) : BreachCountResult()
}

View File

@@ -12,5 +12,8 @@ sealed class DeleteAccountResult {
/**
* There was an error deleting the account.
*/
data class Error(val message: String?) : DeleteAccountResult()
data class Error(
val message: String?,
val error: Throwable?,
) : DeleteAccountResult()
}

View File

@@ -18,5 +18,8 @@ sealed class EmailTokenResult {
/**
* There was an error validating the token.
*/
data class Error(val message: String?) : EmailTokenResult()
data class Error(
val message: String?,
val error: Throwable?,
) : EmailTokenResult()
}

View File

@@ -12,5 +12,7 @@ sealed class KnownDeviceResult {
/**
* There was an error determining if this is a known device.
*/
data object Error : KnownDeviceResult()
data class Error(
val error: Throwable,
) : KnownDeviceResult()
}

View File

@@ -22,7 +22,10 @@ sealed class LoginResult {
/**
* There was an error logging in.
*/
data class Error(val errorMessage: String?) : LoginResult()
data class Error(
val errorMessage: String?,
val error: Throwable?,
) : LoginResult()
/**
* There was an error while logging into an unofficial Bitwarden server.

View File

@@ -8,9 +8,12 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
* the necessary `message` if applicable.
*/
fun VaultUnlockError.toLoginErrorResult(): LoginResult.Error = when (this) {
is VaultUnlockResult.AuthenticationError -> LoginResult.Error(this.message)
VaultUnlockResult.BiometricDecodingError,
VaultUnlockResult.GenericError,
VaultUnlockResult.InvalidStateError,
-> LoginResult.Error(errorMessage = null)
is VaultUnlockResult.AuthenticationError -> {
LoginResult.Error(errorMessage = this.message, error = this.error)
}
is VaultUnlockResult.BiometricDecodingError,
is VaultUnlockResult.GenericError,
is VaultUnlockResult.InvalidStateError,
-> LoginResult.Error(errorMessage = null, error = this.error)
}

View File

@@ -12,5 +12,7 @@ sealed class NewSsoUserResult {
/**
* There was an error while truing to create the new user.
*/
data object Failure : NewSsoUserResult()
data class Failure(
val error: Throwable,
) : NewSsoUserResult()
}

View File

@@ -22,5 +22,7 @@ sealed class OrganizationDomainSsoDetailsResult {
/**
* The request failed.
*/
data object Failure : OrganizationDomainSsoDetailsResult()
data class Failure(
val error: Throwable,
) : OrganizationDomainSsoDetailsResult()
}

View File

@@ -13,5 +13,8 @@ sealed class PasswordHintResult {
/**
* There was an error.
*/
data class Error(val message: String?) : PasswordHintResult()
data class Error(
val message: String?,
val error: Throwable?,
) : PasswordHintResult()
}

View File

@@ -16,5 +16,7 @@ sealed class PasswordStrengthResult {
/**
* There was an error determining the password strength.
*/
data object Error : PasswordStrengthResult()
data class Error(
val error: Throwable,
) : PasswordStrengthResult()
}

View File

@@ -16,5 +16,6 @@ sealed class PrevalidateSsoResult {
*/
data class Failure(
val message: String? = null,
val error: Throwable?,
) : PrevalidateSsoResult()
}

View File

@@ -23,7 +23,10 @@ sealed class RegisterResult {
*
* @param errorMessage a message describing the error.
*/
data class Error(val errorMessage: String?) : RegisterResult()
data class Error(
val errorMessage: String?,
val error: Throwable?,
) : RegisterResult()
/**
* Password hash was found in a data breach.

View File

@@ -12,5 +12,7 @@ sealed class RemovePasswordResult {
/**
* There was an error removing the password.
*/
data object Error : RemovePasswordResult()
data class Error(
val error: Throwable,
) : RemovePasswordResult()
}

View File

@@ -13,5 +13,8 @@ sealed class RequestOtpResult {
/**
* Represents a failure to send the one-time passcode.
*/
data class Error(val message: String?) : RequestOtpResult()
data class Error(
val message: String?,
val error: Throwable,
) : RequestOtpResult()
}

View File

@@ -13,5 +13,8 @@ sealed class ResendEmailResult {
/**
* There was an error.
*/
data class Error(val message: String?) : ResendEmailResult()
data class Error(
val message: String?,
val error: Throwable,
) : ResendEmailResult()
}

View File

@@ -12,5 +12,7 @@ sealed class ResetPasswordResult {
/**
* There was an error resetting the password.
*/
data object Error : ResetPasswordResult()
data class Error(
val error: Throwable,
) : ResetPasswordResult()
}

View File

@@ -18,5 +18,8 @@ sealed class SendVerificationEmailResult {
*
* @param errorMessage a message describing the error.
*/
data class Error(val errorMessage: String?) : SendVerificationEmailResult()
data class Error(
val errorMessage: String?,
val error: Throwable?,
) : SendVerificationEmailResult()
}

View File

@@ -12,5 +12,7 @@ sealed class SetPasswordResult {
/**
* There was an error setting the password.
*/
data object Error : SetPasswordResult()
data class Error(
val error: Throwable,
) : SetPasswordResult()
}

View File

@@ -14,5 +14,7 @@ sealed class UserFingerprintResult {
/**
* There was an error getting the user fingerprint.
*/
data object Error : UserFingerprintResult()
data class Error(
val error: Throwable,
) : UserFingerprintResult()
}

View File

@@ -15,5 +15,7 @@ sealed class ValidatePasswordResult {
/**
* There was an error determining if the validity of the password.
*/
data object Error : ValidatePasswordResult()
data class Error(
val error: Throwable,
) : ValidatePasswordResult()
}

View File

@@ -14,5 +14,7 @@ sealed class ValidatePinResult {
/**
* There was an error determining if the validity of the PIN.
*/
data object Error : ValidatePinResult()
data class Error(
val error: Throwable,
) : ValidatePinResult()
}

View File

@@ -18,5 +18,7 @@ sealed class VerifiedOrganizationDomainSsoDetailsResult {
/**
* The request failed.
*/
data object Failure : VerifiedOrganizationDomainSsoDetailsResult()
data class Failure(
val error: Throwable,
) : VerifiedOrganizationDomainSsoDetailsResult()
}

View File

@@ -13,5 +13,8 @@ sealed class VerifyOtpResult {
/**
* Represents a failure to verify the one-time passcode.
*/
data class NotVerified(val errorMessage: String?) : VerifyOtpResult()
data class NotVerified(
val errorMessage: String?,
val error: Throwable,
) : VerifyOtpResult()
}

View File

@@ -32,6 +32,11 @@ class BitwardenAccessibilityService : AccessibilityService() {
override fun onInterrupt() = Unit
override fun onCreate() {
super.onCreate()
accessibilityEnabledManager.refreshAccessibilityEnabledFromSettings()
}
override fun onUnbind(intent: Intent?): Boolean {
return super
.onUnbind(intent)

View File

@@ -3,7 +3,9 @@ package com.x8bit.bitwarden.data.autofill.accessibility.util
import android.content.Context
import android.provider.Settings
import com.x8bit.bitwarden.LEGACY_ACCESSIBILITY_SERVICE_NAME
import com.x8bit.bitwarden.LEGACY_SHORT_ACCESSIBILITY_SERVICE_NAME
import com.x8bit.bitwarden.data.autofill.accessibility.BitwardenAccessibilityService
import com.x8bit.bitwarden.data.autofill.util.containsAnyTerms
/**
* Helper method to determine if the [BitwardenAccessibilityService] is enabled.
@@ -11,16 +13,25 @@ import com.x8bit.bitwarden.data.autofill.accessibility.BitwardenAccessibilitySer
val Context.isAccessibilityServiceEnabled: Boolean
get() {
val appContext = this.applicationContext
val accessibilityServiceName = appContext
.packageName
?.let { "$it/$LEGACY_ACCESSIBILITY_SERVICE_NAME" }
?: return false
val packageName = appContext.packageName
val accessibilityServiceName = packageName?.let {
"$it/$LEGACY_ACCESSIBILITY_SERVICE_NAME"
}
val shortAccessibilityServiceName = packageName.let {
"$it/$LEGACY_SHORT_ACCESSIBILITY_SERVICE_NAME"
}
return Settings
.Secure
.getString(
appContext.contentResolver,
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
)
?.contains(accessibilityServiceName)
?.containsAnyTerms(
terms = listOfNotNull(
accessibilityServiceName,
shortAccessibilityServiceName,
),
ignoreCase = true,
)
?: false
}

View File

@@ -267,7 +267,7 @@ class Fido2ProviderProcessorImpl(
val result = vaultRepository
.getDecryptedFido2CredentialAutofillViews(cipherViews)
return when (result) {
DecryptFido2CredentialAutofillViewResult.Error -> {
is DecryptFido2CredentialAutofillViewResult.Error -> {
throw GetCredentialUnknownException("Error decrypting credentials.")
}

View File

@@ -0,0 +1,8 @@
package com.x8bit.bitwarden.data.platform.error
/**
* An exception indicating that a required property was missing.
*/
class MissingPropertyException(
propertyName: String,
) : IllegalStateException("Missing the required $propertyName property")

View File

@@ -0,0 +1,6 @@
package com.x8bit.bitwarden.data.platform.error
/**
* An exception indicating that there is currently no active user when one is required.
*/
class NoActiveUserException : IllegalStateException("No current active user!")

View File

@@ -158,7 +158,7 @@ class FirstTimeActionManagerImpl @Inject constructor(
override val shouldShowAddLoginCoachMarkFlow: Flow<Boolean>
get() = settingsDiskSource
.getShouldShowAddLoginCoachMarkFlow()
.map { it ?: true }
.map { it != false }
.mapFalseIfAnyLoginCiphersAvailable()
.combine(
featureFlagManager.getFeatureFlagFlow(FlagKey.OnboardingFlow),
@@ -172,11 +172,13 @@ class FirstTimeActionManagerImpl @Inject constructor(
override val shouldShowGeneratorCoachMarkFlow: Flow<Boolean>
get() = settingsDiskSource
.getShouldShowGeneratorCoachMarkFlow()
.map { it ?: true }
.map { it != false }
.mapFalseIfAnyLoginCiphersAvailable()
.combine(
featureFlagManager.getFeatureFlagFlow(FlagKey.OnboardingFlow),
) { shouldShow, featureFlagEnabled ->
// If the feature flag is off always return true so observers know
// the card has not been shown.
shouldShow && featureFlagEnabled
}
.distinctUntilChanged()
@@ -298,8 +300,8 @@ class FirstTimeActionManagerImpl @Inject constructor(
}
/**
* If there are any existing "Login" type ciphers then we'll map the current value
* of the receiver Flow to `false`.
* If there are any existing "Login" type ciphers then we'll map the current value
* of the receiver Flow to `false`.
*/
@OptIn(ExperimentalCoroutinesApi::class)
private fun Flow<Boolean>.mapFalseIfAnyLoginCiphersAvailable(): Flow<Boolean> =
@@ -310,8 +312,10 @@ class FirstTimeActionManagerImpl @Inject constructor(
combine(
flow = this,
flow2 = vaultDiskSource.getCiphers(activeUserId),
) { currentValue, ciphers ->
currentValue && ciphers.none { it.login != null }
) { receiverCurrentValue, ciphers ->
receiverCurrentValue && ciphers.none {
it.login != null && it.organizationId == null
}
}
}
.distinctUntilChanged()

View File

@@ -5,6 +5,7 @@ import android.security.KeyChain
import android.security.KeyChainException
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 timber.log.Timber
import java.io.IOException
@@ -24,7 +25,7 @@ class KeyManagerImpl(
private val context: Context,
) : KeyManager {
@Suppress("CyclomaticComplexMethod")
@Suppress("LongMethod", "CyclomaticComplexMethod")
override fun importMutualTlsCertificate(
key: ByteArray,
alias: String,
@@ -35,28 +36,29 @@ class KeyManagerImpl(
.inputStream()
.use { stream ->
try {
KeyStore.getInstance(KEYSTORE_TYPE_PKCS12)
KeyStore
.getInstance(KEYSTORE_TYPE_PKCS12)
.also { it.load(stream, password.toCharArray()) }
} catch (e: KeyStoreException) {
Timber.Forest.e(e, "Failed to load PKCS12 bytes")
return ImportPrivateKeyResult.Error.UnsupportedKey
return ImportPrivateKeyResult.Error.UnsupportedKey(throwable = e)
} catch (e: IOException) {
Timber.Forest.e(e, "Format or password error while loading PKCS12 bytes")
return when (e.cause) {
is UnrecoverableKeyException -> {
ImportPrivateKeyResult.Error.UnrecoverableKey
ImportPrivateKeyResult.Error.UnrecoverableKey(throwable = e)
}
else -> {
ImportPrivateKeyResult.Error.KeyStoreOperationFailed
ImportPrivateKeyResult.Error.KeyStoreOperationFailed(throwable = e)
}
}
} catch (e: CertificateException) {
Timber.Forest.e(e, "Unable to load certificate chain")
return ImportPrivateKeyResult.Error.InvalidCertificateChain
return ImportPrivateKeyResult.Error.InvalidCertificateChain(throwable = e)
} catch (e: NoSuchAlgorithmException) {
Timber.Forest.e(e, "Cryptographic algorithm not supported")
return ImportPrivateKeyResult.Error.UnsupportedKey
return ImportPrivateKeyResult.Error.UnsupportedKey(throwable = e)
}
}
@@ -64,22 +66,29 @@ class KeyManagerImpl(
val internalAlias = pkcs12KeyStore.aliases()
?.takeIf { it.hasMoreElements() }
?.nextElement()
?: return ImportPrivateKeyResult.Error.UnsupportedKey
?: return ImportPrivateKeyResult.Error.UnsupportedKey(
throwable = MissingPropertyException("Internal Alias"),
)
// Step 3: Extract PrivateKey and X.509 certificate from the KeyStore and verify
// certificate alias.
val privateKey = try {
pkcs12KeyStore.getKey(internalAlias, password.toCharArray())
?: return ImportPrivateKeyResult.Error.UnrecoverableKey
pkcs12KeyStore
.getKey(internalAlias, password.toCharArray())
?: return ImportPrivateKeyResult.Error.UnrecoverableKey(
throwable = MissingPropertyException("Private Key"),
)
} catch (e: UnrecoverableKeyException) {
Timber.Forest.e(e, "Failed to get private key")
return ImportPrivateKeyResult.Error.UnrecoverableKey
return ImportPrivateKeyResult.Error.UnrecoverableKey(throwable = e)
}
val certChain: Array<Certificate> = pkcs12KeyStore
.getCertificateChain(internalAlias)
?.takeUnless { it.isEmpty() }
?: return ImportPrivateKeyResult.Error.InvalidCertificateChain
?: return ImportPrivateKeyResult.Error.InvalidCertificateChain(
throwable = MissingPropertyException("Certificate Chain"),
)
// Step 4: Store the private key and X.509 certificate in the AndroidKeyStore if the alias
// does not exists.
@@ -92,7 +101,7 @@ class KeyManagerImpl(
setKeyEntry(alias, privateKey, null, certChain)
} catch (e: KeyStoreException) {
Timber.Forest.e(e, "Failed to import key into Android KeyStore")
return ImportPrivateKeyResult.Error.KeyStoreOperationFailed
return ImportPrivateKeyResult.Error.KeyStoreOperationFailed(throwable = e)
}
}
return ImportPrivateKeyResult.Success(alias)

View File

@@ -44,12 +44,13 @@ sealed class FlagKey<out T : Any> {
AnonAddySelfHostAlias,
SimpleLoginSelfHostAlias,
ChromeAutofill,
MobileErrorReporting,
)
}
}
/**
* Data object holding the key for syncing with the Bitwarden Authenticator app.
* Data object holding the key for syncing with the Bitwarden Authenticator app.
*/
data object AuthenticatorSync : FlagKey<Boolean>() {
override val keyName: String = "enable-pm-bwa-sync"
@@ -66,6 +67,15 @@ sealed class FlagKey<out T : Any> {
override val isRemotelyConfigured: Boolean = true
}
/**
* Data object holding the key for syncing with the Bitwarden Authenticator app.
*/
data object MobileErrorReporting : FlagKey<Boolean>() {
override val keyName: String = "mobile-error-reporting"
override val defaultValue: Boolean = false
override val isRemotelyConfigured: Boolean = false
}
/**
* Data object holding the feature flag key for the Onboarding Carousel feature.
*/
@@ -218,7 +228,7 @@ sealed class FlagKey<out T : Any> {
* autofill.
*/
data object ChromeAutofill : FlagKey<Boolean>() {
override val keyName: String = "enable-pm-chrome-autofill"
override val keyName: String = "android-chrome-autofill"
override val defaultValue: Boolean = false
override val isRemotelyConfigured: Boolean = true
}

View File

@@ -16,30 +16,44 @@ sealed class ImportPrivateKeyResult {
* Represents a generic error during the import process.
*/
sealed class Error : ImportPrivateKeyResult() {
/**
* The underlying error.
*/
abstract val throwable: Throwable?
/**
* Indicates that the provided key is unrecoverable or the password is incorrect.
*/
data object UnrecoverableKey : Error()
data class UnrecoverableKey(
override val throwable: Throwable,
) : Error()
/**
* Indicates that the certificate chain associated with the key is invalid.
*/
data object InvalidCertificateChain : Error()
data class InvalidCertificateChain(
override val throwable: Throwable,
) : Error()
/**
* Indicates that the specified alias is already in use.
*/
data object DuplicateAlias : Error()
data object DuplicateAlias : Error() {
override val throwable: Throwable? = null
}
/**
* Indicates that an error occurred during the key store operation.
*/
data object KeyStoreOperationFailed : Error()
data class KeyStoreOperationFailed(
override val throwable: Throwable,
) : Error()
/**
* Indicates the provided key is not supported.
*/
data object UnsupportedKey : Error()
data class UnsupportedKey(
override val throwable: Throwable,
) : Error()
}
}

View File

@@ -2,8 +2,9 @@ package com.x8bit.bitwarden.data.platform.manager.model
import com.x8bit.bitwarden.data.platform.manager.PushManager
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
import java.time.ZonedDateTime
/**
@@ -12,6 +13,7 @@ import java.time.ZonedDateTime
* Note: The data we receive is not always reliable, so everything is nullable and we validate the
* data in the [PushManager] as necessary.
*/
@OptIn(ExperimentalSerializationApi::class)
@Serializable
sealed class NotificationPayload {
/**
@@ -24,12 +26,12 @@ sealed class NotificationPayload {
*/
@Serializable
data class SyncCipherNotification(
@SerialName("Id") val cipherId: String?,
@SerialName("UserId") override val userId: String?,
@SerialName("OrganizationId") val organizationId: String?,
@SerialName("CollectionIds") val collectionIds: List<String>?,
@JsonNames("Id", "id") val cipherId: String?,
@JsonNames("UserId", "userId") override val userId: String?,
@JsonNames("OrganizationId", "organizationId") val organizationId: String?,
@JsonNames("CollectionIds", "collectionIds") val collectionIds: List<String>?,
@Contextual
@SerialName("RevisionDate") val revisionDate: ZonedDateTime?,
@JsonNames("RevisionDate", "revisionDate") val revisionDate: ZonedDateTime?,
) : NotificationPayload()
/**
@@ -37,10 +39,10 @@ sealed class NotificationPayload {
*/
@Serializable
data class SyncFolderNotification(
@SerialName("Id") val folderId: String?,
@SerialName("UserId") override val userId: String?,
@JsonNames("Id", "id") val folderId: String?,
@JsonNames("UserId", "userId") override val userId: String?,
@Contextual
@SerialName("RevisionDate") val revisionDate: ZonedDateTime?,
@JsonNames("RevisionDate", "revisionDate") val revisionDate: ZonedDateTime?,
) : NotificationPayload()
/**
@@ -48,9 +50,9 @@ sealed class NotificationPayload {
*/
@Serializable
data class UserNotification(
@SerialName("UserId") override val userId: String?,
@JsonNames("UserId", "userId") override val userId: String?,
@Contextual
@SerialName("Date") val date: ZonedDateTime?,
@JsonNames("Date", "date") val date: ZonedDateTime?,
) : NotificationPayload()
/**
@@ -58,10 +60,10 @@ sealed class NotificationPayload {
*/
@Serializable
data class SyncSendNotification(
@SerialName("Id") val sendId: String?,
@SerialName("UserId") override val userId: String?,
@JsonNames("Id", "id") val sendId: String?,
@JsonNames("UserId", "userId") override val userId: String?,
@Contextual
@SerialName("RevisionDate") val revisionDate: ZonedDateTime?,
@JsonNames("RevisionDate", "revisionDate") val revisionDate: ZonedDateTime?,
) : NotificationPayload()
/**
@@ -69,7 +71,7 @@ sealed class NotificationPayload {
*/
@Serializable
data class PasswordlessRequestNotification(
@SerialName("UserId") override val userId: String?,
@SerialName("Id") val loginRequestId: String?,
@JsonNames("UserId", "userId") override val userId: String?,
@JsonNames("Id", "id") val loginRequestId: String?,
) : NotificationPayload()
}

View File

@@ -72,9 +72,9 @@ class AuthenticatorBridgeRepositoryImpl(
when (unlockResult) {
is VaultUnlockResult.AuthenticationError,
VaultUnlockResult.BiometricDecodingError,
VaultUnlockResult.GenericError,
VaultUnlockResult.InvalidStateError,
is VaultUnlockResult.BiometricDecodingError,
is VaultUnlockResult.GenericError,
is VaultUnlockResult.InvalidStateError,
-> {
// Not being able to unlock the user's vault with the
// decrypted unlock key is an unexpected case, but if it does
@@ -116,7 +116,10 @@ class AuthenticatorBridgeRepositoryImpl(
// Lock the user's vault if we unlocked it for this operation:
if (!isVaultAlreadyUnlocked) {
vaultRepository.lockVault(userId)
vaultRepository.lockVault(
userId = userId,
isUserInitiated = false,
)
}
SharedAccountData.Account(

View File

@@ -10,6 +10,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager
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.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
@@ -380,12 +381,13 @@ class SettingsRepositoryImpl(
}
override suspend fun getUserFingerprint(): UserFingerprintResult {
val userId = activeUserId ?: return UserFingerprintResult.Error
val userId = activeUserId
?: return UserFingerprintResult.Error(error = NoActiveUserException())
return vaultSdkSource
.getUserFingerprint(userId)
.fold(
onFailure = { UserFingerprintResult.Error },
onFailure = { UserFingerprintResult.Error(error = it) },
onSuccess = { UserFingerprintResult.Success(it) },
)
}
@@ -492,7 +494,8 @@ class SettingsRepositoryImpl(
}
override suspend fun setupBiometricsKey(cipher: Cipher): BiometricsKeyResult {
val userId = activeUserId ?: return BiometricsKeyResult.Error
val userId = activeUserId
?: return BiometricsKeyResult.Error(error = NoActiveUserException())
return vaultSdkSource
.getUserEncryptionKey(userId = userId)
.onSuccess { biometricsKey ->
@@ -506,7 +509,7 @@ class SettingsRepositoryImpl(
}
.fold(
onSuccess = { BiometricsKeyResult.Success },
onFailure = { BiometricsKeyResult.Error },
onFailure = { BiometricsKeyResult.Error(error = it) },
)
}

View File

@@ -12,5 +12,7 @@ sealed class BiometricsKeyResult {
/**
* Generic error while setting up the biometrics key.
*/
data object Error : BiometricsKeyResult()
data class Error(
val error: Throwable,
) : BiometricsKeyResult()
}

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.platform.util
import android.os.Build
import com.x8bit.bitwarden.BuildConfig
/**
@@ -7,3 +8,55 @@ import com.x8bit.bitwarden.BuildConfig
*/
val isFdroid: Boolean
get() = BuildConfig.FLAVOR == "fdroid"
/**
* A string that represents a displayable app version.
*/
val versionData: String
get() = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
/**
* A string that represents device data.
*/
val deviceData: String get() = "$deviceBrandModel $osInfo $buildInfo"
/**
* A string representing the CI information if available.
*/
val ciBuildInfo: String? get() = BuildConfig.CI_INFO.takeUnless { it.isBlank() }
/**
* A string representing the build flavor or blank if it is the standard configuration.
*/
private val buildFlavorName: String
get() = when (BuildConfig.FLAVOR) {
"standard" -> ""
else -> "-${BuildConfig.FLAVOR}"
}
/**
* A string representing the build type.
*/
private val buildTypeName: String
get() = when (BuildConfig.BUILD_TYPE) {
"debug" -> "dev"
"release" -> "prod"
else -> BuildConfig.BUILD_TYPE
}
/**
* A string representing the device brand and model.
*/
private val deviceBrandModel: String get() = "\uD83D\uDCF1 ${Build.BRAND} ${Build.MODEL}"
/**
* A string representing the operating system information.
*/
private val osInfo: String get() = "\uD83E\uDD16 ${Build.VERSION.RELEASE}@${Build.VERSION.SDK_INT}"
/**
* A string representing the build information.
*/
private val buildInfo: String
get() = "\uD83D\uDCE6 $buildTypeName" +
buildFlavorName.takeUnless { it.isBlank() }?.let { " $it" }.orEmpty()

View File

@@ -130,7 +130,7 @@ class GeneratorRepositoryImpl(
}
GeneratedPasswordResult.Success(generatedPassword)
},
onFailure = { GeneratedPasswordResult.InvalidRequest },
onFailure = { GeneratedPasswordResult.InvalidRequest(error = it) },
)
override suspend fun generatePassphrase(
@@ -149,7 +149,7 @@ class GeneratorRepositoryImpl(
}
GeneratedPassphraseResult.Success(generatedPassphrase)
},
onFailure = { GeneratedPassphraseResult.InvalidRequest },
onFailure = { GeneratedPassphraseResult.InvalidRequest(error = it) },
)
override suspend fun generatePlusAddressedEmail(
@@ -161,7 +161,7 @@ class GeneratorRepositoryImpl(
GeneratedPlusAddressedUsernameResult.Success(generatedEmail)
},
onFailure = {
GeneratedPlusAddressedUsernameResult.InvalidRequest
GeneratedPlusAddressedUsernameResult.InvalidRequest(error = it)
},
)
@@ -174,7 +174,7 @@ class GeneratorRepositoryImpl(
GeneratedCatchAllUsernameResult.Success(generatedEmail)
},
onFailure = {
GeneratedCatchAllUsernameResult.InvalidRequest
GeneratedCatchAllUsernameResult.InvalidRequest(error = it)
},
)
@@ -187,7 +187,7 @@ class GeneratorRepositoryImpl(
GeneratedRandomWordUsernameResult.Success(generatedUsername)
},
onFailure = {
GeneratedRandomWordUsernameResult.InvalidRequest
GeneratedRandomWordUsernameResult.InvalidRequest(error = it)
},
)
@@ -200,7 +200,7 @@ class GeneratorRepositoryImpl(
GeneratedForwardedServiceUsernameResult.Success(generatedEmail)
},
onFailure = {
GeneratedForwardedServiceUsernameResult.InvalidRequest(it.message)
GeneratedForwardedServiceUsernameResult.InvalidRequest(it.message, error = it)
},
)
}

View File

@@ -15,5 +15,5 @@ sealed class GeneratedCatchAllUsernameResult {
/**
* There was an error during the operation.
*/
data object InvalidRequest : GeneratedCatchAllUsernameResult()
data class InvalidRequest(val error: Throwable) : GeneratedCatchAllUsernameResult()
}

View File

@@ -14,5 +14,8 @@ sealed class GeneratedForwardedServiceUsernameResult {
/**
* There was an error during the operation.
*/
data class InvalidRequest(val message: String?) : GeneratedForwardedServiceUsernameResult()
data class InvalidRequest(
val message: String?,
val error: Throwable,
) : GeneratedForwardedServiceUsernameResult()
}

View File

@@ -12,5 +12,5 @@ sealed class GeneratedPassphraseResult {
/**
* There was an error during the operation.
*/
data object InvalidRequest : GeneratedPassphraseResult()
data class InvalidRequest(val error: Throwable) : GeneratedPassphraseResult()
}

View File

@@ -13,5 +13,5 @@ sealed class GeneratedPasswordResult {
/**
* There was an error during the operation.
*/
data object InvalidRequest : GeneratedPasswordResult()
data class InvalidRequest(val error: Throwable) : GeneratedPasswordResult()
}

View File

@@ -14,5 +14,5 @@ sealed class GeneratedPlusAddressedUsernameResult {
/**
* There was an error during the operation.
*/
data object InvalidRequest : GeneratedPlusAddressedUsernameResult()
data class InvalidRequest(val error: Throwable) : GeneratedPlusAddressedUsernameResult()
}

View File

@@ -15,5 +15,5 @@ sealed class GeneratedRandomWordUsernameResult {
/**
* There was an error during the operation.
*/
data object InvalidRequest : GeneratedRandomWordUsernameResult()
data class InvalidRequest(val error: Throwable) : GeneratedRandomWordUsernameResult()
}

View File

@@ -169,7 +169,10 @@ class VaultSdkSourceImpl(
InitializeCryptoResult.Success
} catch (exception: BitwardenException) {
// The only truly expected error from the SDK is an incorrect key/password.
InitializeCryptoResult.AuthenticationError(message = exception.message)
InitializeCryptoResult.AuthenticationError(
message = exception.message,
error = exception,
)
}
}
@@ -187,6 +190,7 @@ class VaultSdkSourceImpl(
// The only truly expected error from the SDK is for incorrect keys.
InitializeCryptoResult.AuthenticationError(
message = exception.message,
error = exception,
)
}
}

View File

@@ -1,19 +0,0 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
import com.bitwarden.vault.CipherView
/**
* Models the result of querying for ciphers with FIDO 2 credentials.
*/
sealed class FindFido2CredentialsResult {
/**
* Indicates the query was executed successfully.
*/
data class Success(val cipherViews: List<CipherView>) : FindFido2CredentialsResult()
/**
* Indicates the query was not executed successfully.
*/
data object Error : FindFido2CredentialsResult()
}

View File

@@ -15,5 +15,6 @@ sealed class InitializeCryptoResult {
*/
data class AuthenticationError(
val message: String? = null,
val error: Throwable,
) : InitializeCryptoResult()
}

View File

@@ -1,17 +0,0 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
/**
* Models the result of saving a FIDO 2 credential.
*/
sealed class SaveCredentialResult {
/**
* Indicates the credential has been saved.
*/
data object Success : SaveCredentialResult()
/**
* Indicates the credential was not saved.
*/
data object Error : SaveCredentialResult()
}

View File

@@ -6,6 +6,7 @@ import com.bitwarden.vault.AttachmentView
import com.bitwarden.vault.Cipher
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
@@ -49,7 +50,8 @@ class CipherManagerImpl(
private val activeUserId: String? get() = authDiskSource.userState?.activeUserId
override suspend fun createCipher(cipherView: CipherView): CreateCipherResult {
val userId = activeUserId ?: return CreateCipherResult.Error
val userId = activeUserId
?: return CreateCipherResult.Error(error = NoActiveUserException())
return vaultSdkSource
.encryptCipher(
userId = userId,
@@ -58,7 +60,7 @@ class CipherManagerImpl(
.flatMap { ciphersService.createCipher(body = it.toEncryptedNetworkCipher()) }
.onSuccess { vaultDiskSource.saveCipher(userId = userId, cipher = it) }
.fold(
onFailure = { CreateCipherResult.Error },
onFailure = { CreateCipherResult.Error(error = it) },
onSuccess = {
reviewPromptManager.registerAddCipherAction()
CreateCipherResult.Success
@@ -70,7 +72,8 @@ class CipherManagerImpl(
cipherView: CipherView,
collectionIds: List<String>,
): CreateCipherResult {
val userId = activeUserId ?: return CreateCipherResult.Error
val userId = activeUserId
?: return CreateCipherResult.Error(error = NoActiveUserException())
return vaultSdkSource
.encryptCipher(
userId = userId,
@@ -91,7 +94,7 @@ class CipherManagerImpl(
)
}
.fold(
onFailure = { CreateCipherResult.Error },
onFailure = { CreateCipherResult.Error(error = it) },
onSuccess = {
reviewPromptManager.registerAddCipherAction()
CreateCipherResult.Success
@@ -100,13 +103,14 @@ class CipherManagerImpl(
}
override suspend fun hardDeleteCipher(cipherId: String): DeleteCipherResult {
val userId = activeUserId ?: return DeleteCipherResult.Error
val userId = activeUserId
?: return DeleteCipherResult.Error(error = NoActiveUserException())
return ciphersService
.hardDeleteCipher(cipherId = cipherId)
.onSuccess { vaultDiskSource.deleteCipher(userId = userId, cipherId = cipherId) }
.fold(
onSuccess = { DeleteCipherResult.Success },
onFailure = { DeleteCipherResult.Error },
onFailure = { DeleteCipherResult.Error(error = it) },
)
}
@@ -114,7 +118,8 @@ class CipherManagerImpl(
cipherId: String,
cipherView: CipherView,
): DeleteCipherResult {
val userId = activeUserId ?: return DeleteCipherResult.Error
val userId = activeUserId
?: return DeleteCipherResult.Error(error = NoActiveUserException())
return cipherView
.encryptCipherAndCheckForMigration(userId = userId, cipherId = cipherId)
.flatMap { cipher ->
@@ -136,7 +141,7 @@ class CipherManagerImpl(
}
.fold(
onSuccess = { DeleteCipherResult.Success },
onFailure = { DeleteCipherResult.Error },
onFailure = { DeleteCipherResult.Error(error = it) },
)
}
@@ -152,7 +157,7 @@ class CipherManagerImpl(
)
.fold(
onSuccess = { DeleteAttachmentResult.Success },
onFailure = { DeleteAttachmentResult.Error },
onFailure = { DeleteAttachmentResult.Error(error = it) },
)
private suspend fun deleteCipherAttachmentForResult(
@@ -160,7 +165,7 @@ class CipherManagerImpl(
attachmentId: String,
cipherView: CipherView,
): Result<Cipher> {
val userId = activeUserId ?: return IllegalStateException("No active user").asFailure()
val userId = activeUserId ?: return NoActiveUserException().asFailure()
return ciphersService
.deleteCipherAttachment(
cipherId = cipherId,
@@ -187,7 +192,8 @@ class CipherManagerImpl(
cipherId: String,
cipherView: CipherView,
): RestoreCipherResult {
val userId = activeUserId ?: return RestoreCipherResult.Error
val userId = activeUserId
?: return RestoreCipherResult.Error(error = NoActiveUserException())
return ciphersService
.restoreCipher(cipherId = cipherId)
.onSuccess {
@@ -198,7 +204,7 @@ class CipherManagerImpl(
}
.fold(
onSuccess = { RestoreCipherResult.Success },
onFailure = { RestoreCipherResult.Error },
onFailure = { RestoreCipherResult.Error(error = it) },
)
}
@@ -206,7 +212,8 @@ class CipherManagerImpl(
cipherId: String,
cipherView: CipherView,
): UpdateCipherResult {
val userId = activeUserId ?: return UpdateCipherResult.Error(errorMessage = null)
val userId = activeUserId
?: return UpdateCipherResult.Error(errorMessage = null, error = NoActiveUserException())
return vaultSdkSource
.encryptCipher(
userId = userId,
@@ -221,7 +228,7 @@ class CipherManagerImpl(
.map { response ->
when (response) {
is UpdateCipherResponseJson.Invalid -> {
UpdateCipherResult.Error(errorMessage = response.message)
UpdateCipherResult.Error(errorMessage = response.message, error = null)
}
is UpdateCipherResponseJson.Success -> {
@@ -234,7 +241,7 @@ class CipherManagerImpl(
}
}
.fold(
onFailure = { UpdateCipherResult.Error(errorMessage = null) },
onFailure = { UpdateCipherResult.Error(errorMessage = null, error = it) },
onSuccess = { it },
)
}
@@ -245,7 +252,7 @@ class CipherManagerImpl(
cipherView: CipherView,
collectionIds: List<String>,
): ShareCipherResult {
val userId = activeUserId ?: return ShareCipherResult.Error
val userId = activeUserId ?: return ShareCipherResult.Error(error = NoActiveUserException())
return migrateAttachments(userId = userId, cipherView = cipherView)
.flatMap {
vaultSdkSource.moveToOrganization(
@@ -271,7 +278,7 @@ class CipherManagerImpl(
)
}
.fold(
onFailure = { ShareCipherResult.Error },
onFailure = { ShareCipherResult.Error(error = it) },
onSuccess = { ShareCipherResult.Success },
)
}
@@ -281,7 +288,7 @@ class CipherManagerImpl(
cipherView: CipherView,
collectionIds: List<String>,
): ShareCipherResult {
val userId = activeUserId ?: return ShareCipherResult.Error
val userId = activeUserId ?: return ShareCipherResult.Error(error = NoActiveUserException())
return ciphersService
.updateCipherCollections(
cipherId = cipherId,
@@ -301,7 +308,7 @@ class CipherManagerImpl(
}
.fold(
onSuccess = { ShareCipherResult.Success },
onFailure = { ShareCipherResult.Error },
onFailure = { ShareCipherResult.Error(error = it) },
)
}
@@ -320,7 +327,7 @@ class CipherManagerImpl(
fileUri = fileUri,
)
.fold(
onFailure = { CreateAttachmentResult.Error },
onFailure = { CreateAttachmentResult.Error(error = it) },
onSuccess = { CreateAttachmentResult.Success(cipherView = it) },
)
@@ -332,7 +339,7 @@ class CipherManagerImpl(
fileName: String?,
fileUri: Uri,
): Result<CipherView> {
val userId = activeUserId ?: return IllegalStateException("No active user").asFailure()
val userId = activeUserId ?: return NoActiveUserException().asFailure()
val attachmentView = AttachmentView(
id = null,
url = null,
@@ -404,14 +411,14 @@ class CipherManagerImpl(
)
.fold(
onSuccess = { DownloadAttachmentResult.Success(file = it) },
onFailure = { DownloadAttachmentResult.Failure },
onFailure = { DownloadAttachmentResult.Failure(error = it) },
)
private suspend fun downloadAttachmentForResult(
cipherView: CipherView,
attachmentId: String,
): Result<File> {
val userId = activeUserId ?: return IllegalStateException("No active user").asFailure()
val userId = activeUserId ?: return NoActiveUserException().asFailure()
val cipher = cipherView
.encryptCipherAndCheckForMigration(
@@ -439,7 +446,10 @@ class CipherManagerImpl(
?: return IllegalStateException("Attachment does not have a url").asFailure()
val encryptedFile = when (val result = fileManager.downloadFileToCache(url)) {
DownloadResult.Failure -> return IllegalStateException("Download failed").asFailure()
is DownloadResult.Failure -> {
return IllegalStateException("Download failed", result.error).asFailure()
}
is DownloadResult.Success -> result.file
}

View File

@@ -44,7 +44,7 @@ class FileManagerImpl(
.getDataStream(url)
.fold(
onSuccess = { it },
onFailure = { return DownloadResult.Failure },
onFailure = { return DownloadResult.Failure(error = it) },
)
// Create a temporary file in cache to write to
@@ -66,7 +66,7 @@ class FileManagerImpl(
}
fos.flush()
} catch (e: RuntimeException) {
return@withContext DownloadResult.Failure
return@withContext DownloadResult.Failure(error = e)
}
}
}
@@ -94,7 +94,7 @@ class FileManagerImpl(
}
}
true
} catch (exception: RuntimeException) {
} catch (_: RuntimeException) {
false
}
}
@@ -111,7 +111,7 @@ class FileManagerImpl(
}
}
true
} catch (exception: RuntimeException) {
} catch (_: RuntimeException) {
false
}
}

View File

@@ -22,6 +22,11 @@ interface VaultLockManager {
*/
val vaultStateEventFlow: Flow<VaultStateEvent>
/**
* Whether the user is coming from the lock flow or not.
*/
var isFromLockFlow: Boolean
/**
* Whether or not the vault is currently locked for the given [userId].
*/
@@ -35,12 +40,12 @@ interface VaultLockManager {
/**
* Locks the vault for the user with the given [userId].
*/
fun lockVault(userId: String)
fun lockVault(userId: String, isUserInitiated: Boolean)
/**
* Locks the vault for the current user if currently unlocked.
*/
fun lockVaultForCurrentUser()
fun lockVaultForCurrentUser(isUserInitiated: Boolean)
/**
* Attempt to unlock the vault with the specified user information.

View File

@@ -16,6 +16,8 @@ import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
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.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.manager.model.AppCreationState
@@ -99,6 +101,8 @@ class VaultLockManagerImpl(
override val vaultStateEventFlow: Flow<VaultStateEvent>
get() = mutableVaultStateEventSharedFlow.asSharedFlow()
override var isFromLockFlow: Boolean = false
init {
observeAppCreationChanges()
observeAppForegroundChanges()
@@ -117,13 +121,17 @@ class VaultLockManagerImpl(
override fun isVaultUnlocking(userId: String): Boolean =
mutableVaultUnlockDataStateFlow.value.statusFor(userId) == VaultUnlockData.Status.UNLOCKING
override fun lockVault(userId: String) {
override fun lockVault(userId: String, isUserInitiated: Boolean) {
isFromLockFlow = isUserInitiated
setVaultToLocked(userId = userId)
}
override fun lockVaultForCurrentUser() {
override fun lockVaultForCurrentUser(isUserInitiated: Boolean) {
activeUserId?.let {
lockVault(it)
lockVault(
userId = it,
isUserInitiated = isUserInitiated,
)
}
}
@@ -167,7 +175,7 @@ class VaultLockManagerImpl(
.fold(
onFailure = {
incrementInvalidUnlockCount(userId = userId)
VaultUnlockResult.GenericError
VaultUnlockResult.GenericError(error = it)
},
onSuccess = { initializeCryptoResult ->
initializeCryptoResult
@@ -605,9 +613,11 @@ class VaultLockManagerImpl(
initUserCryptoMethod: InitUserCryptoMethod,
): VaultUnlockResult {
val account = authDiskSource.userState?.accounts?.get(userId)
?: return VaultUnlockResult.InvalidStateError
?: return VaultUnlockResult.InvalidStateError(error = NoActiveUserException())
val privateKey = authDiskSource.getPrivateKey(userId = userId)
?: return VaultUnlockResult.InvalidStateError
?: return VaultUnlockResult.InvalidStateError(
error = MissingPropertyException("Private key"),
)
val organizationKeys = authDiskSource.getOrganizationKeys(userId = userId)
return unlockVault(
userId = userId,

View File

@@ -14,5 +14,7 @@ sealed class DownloadResult {
/**
* The download failed.
*/
data object Failure : DownloadResult()
data class Failure(
val error: Throwable,
) : DownloadResult()
}

View File

@@ -21,6 +21,8 @@ import com.x8bit.bitwarden.data.auth.repository.util.toUpdatedUserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.datasource.network.util.isNoConnectionError
import com.x8bit.bitwarden.data.platform.error.MissingPropertyException
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
@@ -509,11 +511,13 @@ class VaultRepositoryImpl(
): DecryptFido2CredentialAutofillViewResult {
return vaultSdkSource
.decryptFido2CredentialAutofillViews(
userId = activeUserId ?: return DecryptFido2CredentialAutofillViewResult.Error,
userId = activeUserId ?: return DecryptFido2CredentialAutofillViewResult.Error(
error = NoActiveUserException(),
),
cipherViews = cipherViewList.toTypedArray(),
)
.fold(
onFailure = { DecryptFido2CredentialAutofillViewResult.Error },
onFailure = { DecryptFido2CredentialAutofillViewResult.Error(error = it) },
onSuccess = { DecryptFido2CredentialAutofillViewResult.Success(it) },
)
}
@@ -545,36 +549,54 @@ class VaultRepositoryImpl(
)
override suspend fun unlockVaultWithBiometrics(cipher: Cipher): VaultUnlockResult {
val userId = activeUserId ?: return VaultUnlockResult.InvalidStateError
val userId = activeUserId
?: return VaultUnlockResult.InvalidStateError(error = NoActiveUserException())
val biometricsKey = authDiskSource
.getUserBiometricUnlockKey(userId = userId)
?: return VaultUnlockResult.InvalidStateError
?: return VaultUnlockResult.InvalidStateError(
error = MissingPropertyException("Biometric key"),
)
val iv = authDiskSource.getUserBiometricInitVector(userId = userId)
val decryptedUserKey = iv
?.let {
try {
cipher
.doFinal(biometricsKey.toByteArray(Charsets.ISO_8859_1))
.decodeToString()
} catch (e: GeneralSecurityException) {
return VaultUnlockResult.BiometricDecodingError(error = e)
}
}
?: biometricsKey
val encryptedBiometricsKey = if (iv == null) {
// Attempting to setup an encrypted pin before unlocking, if this fails we send back
// the biometrics error and users will need to sign in another way and re-setup
// biometrics.
try {
cipher
.doFinal(biometricsKey.encodeToByteArray())
.toString(Charsets.ISO_8859_1)
} catch (e: GeneralSecurityException) {
return VaultUnlockResult.BiometricDecodingError(error = e)
}
} else {
null
}
return this
.unlockVaultForUser(
userId = userId,
initUserCryptoMethod = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = iv
?.let {
try {
cipher
.doFinal(biometricsKey.toByteArray(Charsets.ISO_8859_1))
.decodeToString()
} catch (_: GeneralSecurityException) {
return VaultUnlockResult.BiometricDecodingError
}
}
?: biometricsKey,
decryptedUserKey = decryptedUserKey,
),
)
.also {
if (it is VaultUnlockResult.Success) {
if (iv == null) {
encryptedBiometricsKey?.let {
// If this key is present, we store it and the associated IV for future use
// since we want to migrate the user to a more secure form of biometrics.
authDiskSource.storeUserBiometricUnlockKey(
userId = userId,
biometricsKey = cipher
.doFinal(biometricsKey.encodeToByteArray())
.toString(Charsets.ISO_8859_1),
biometricsKey = it,
)
authDiskSource.storeUserBiometricInitVector(userId = userId, iv = cipher.iv)
}
@@ -586,9 +608,12 @@ class VaultRepositoryImpl(
override suspend fun unlockVaultWithMasterPassword(
masterPassword: String,
): VaultUnlockResult {
val userId = activeUserId ?: return VaultUnlockResult.InvalidStateError
val userId = activeUserId
?: return VaultUnlockResult.InvalidStateError(error = NoActiveUserException())
val userKey = authDiskSource.getUserKey(userId = userId)
?: return VaultUnlockResult.InvalidStateError
?: return VaultUnlockResult.InvalidStateError(
error = MissingPropertyException("User key"),
)
return unlockVaultForUser(
userId = userId,
initUserCryptoMethod = InitUserCryptoMethod.Password(
@@ -606,9 +631,12 @@ class VaultRepositoryImpl(
override suspend fun unlockVaultWithPin(
pin: String,
): VaultUnlockResult {
val userId = activeUserId ?: return VaultUnlockResult.InvalidStateError
val userId = activeUserId
?: return VaultUnlockResult.InvalidStateError(error = NoActiveUserException())
val pinProtectedUserKey = authDiskSource.getPinProtectedUserKey(userId = userId)
?: return VaultUnlockResult.InvalidStateError
?: return VaultUnlockResult.InvalidStateError(
error = MissingPropertyException("Pin protected key"),
)
return unlockVaultForUser(
userId = userId,
initUserCryptoMethod = InitUserCryptoMethod.Pin(
@@ -622,7 +650,8 @@ class VaultRepositoryImpl(
sendView: SendView,
fileUri: Uri?,
): CreateSendResult {
val userId = activeUserId ?: return CreateSendResult.Error(message = null)
val userId = activeUserId
?: return CreateSendResult.Error(message = null, error = NoActiveUserException())
return vaultSdkSource
.encryptSend(
userId = userId,
@@ -639,6 +668,7 @@ class VaultRepositoryImpl(
is CreateSendJsonResponse.Invalid -> {
return CreateSendResult.Error(
message = createSendResponse.firstValidationErrorMessage,
error = null,
)
}
@@ -656,7 +686,7 @@ class VaultRepositoryImpl(
)
}
.fold(
onFailure = { CreateSendResult.Error(message = null) },
onFailure = { CreateSendResult.Error(message = null, error = it) },
onSuccess = {
reviewPromptManager.registerCreateSendAction()
CreateSendResult.Success(it)
@@ -668,7 +698,11 @@ class VaultRepositoryImpl(
sendId: String,
sendView: SendView,
): UpdateSendResult {
val userId = activeUserId ?: return UpdateSendResult.Error(null)
val userId = activeUserId
?: return UpdateSendResult.Error(
errorMessage = null,
error = NoActiveUserException(),
)
return vaultSdkSource
.encryptSend(
userId = userId,
@@ -681,11 +715,11 @@ class VaultRepositoryImpl(
)
}
.fold(
onFailure = { UpdateSendResult.Error(errorMessage = null) },
onFailure = { UpdateSendResult.Error(errorMessage = null, error = it) },
onSuccess = { response ->
when (response) {
is UpdateSendResponseJson.Invalid -> {
UpdateSendResult.Error(errorMessage = response.message)
UpdateSendResult.Error(errorMessage = response.message, error = null)
}
is UpdateSendResponseJson.Success -> {
@@ -695,9 +729,12 @@ class VaultRepositoryImpl(
userId = userId,
send = response.send.toEncryptedSdkSend(),
)
.getOrNull()
?.let { UpdateSendResult.Success(sendView = it) }
?: UpdateSendResult.Error(errorMessage = null)
.fold(
onSuccess = { UpdateSendResult.Success(sendView = it) },
onFailure = {
UpdateSendResult.Error(errorMessage = null, error = it)
},
)
}
}
},
@@ -705,14 +742,21 @@ class VaultRepositoryImpl(
}
override suspend fun removePasswordSend(sendId: String): RemovePasswordSendResult {
val userId = activeUserId ?: return RemovePasswordSendResult.Error(null)
val userId = activeUserId
?: return RemovePasswordSendResult.Error(
errorMessage = null,
error = NoActiveUserException(),
)
return sendsService
.removeSendPassword(sendId = sendId)
.fold(
onSuccess = { response ->
when (response) {
is UpdateSendResponseJson.Invalid -> {
RemovePasswordSendResult.Error(errorMessage = response.message)
RemovePasswordSendResult.Error(
errorMessage = response.message,
error = null,
)
}
is UpdateSendResponseJson.Success -> {
@@ -722,24 +766,30 @@ class VaultRepositoryImpl(
userId = userId,
send = response.send.toEncryptedSdkSend(),
)
.getOrNull()
?.let { RemovePasswordSendResult.Success(sendView = it) }
?: RemovePasswordSendResult.Error(errorMessage = null)
.fold(
onSuccess = { RemovePasswordSendResult.Success(sendView = it) },
onFailure = {
RemovePasswordSendResult.Error(
errorMessage = null,
error = it,
)
},
)
}
}
},
onFailure = { RemovePasswordSendResult.Error(errorMessage = null) },
onFailure = { RemovePasswordSendResult.Error(errorMessage = null, error = it) },
)
}
override suspend fun deleteSend(sendId: String): DeleteSendResult {
val userId = activeUserId ?: return DeleteSendResult.Error
val userId = activeUserId ?: return DeleteSendResult.Error(error = NoActiveUserException())
return sendsService
.deleteSend(sendId)
.onSuccess { vaultDiskSource.deleteSend(userId, sendId) }
.fold(
onSuccess = { DeleteSendResult.Success },
onFailure = { DeleteSendResult.Error },
onFailure = { DeleteSendResult.Error(error = it) },
)
}
@@ -747,7 +797,8 @@ class VaultRepositoryImpl(
totpCode: String,
time: DateTime,
): GenerateTotpResult {
val userId = activeUserId ?: return GenerateTotpResult.Error
val userId = activeUserId
?: return GenerateTotpResult.Error(error = NoActiveUserException())
return vaultSdkSource.generateTotp(
time = time,
userId = userId,
@@ -760,12 +811,13 @@ class VaultRepositoryImpl(
periodSeconds = it.period.toInt(),
)
},
onFailure = { GenerateTotpResult.Error },
onFailure = { GenerateTotpResult.Error(error = it) },
)
}
override suspend fun createFolder(folderView: FolderView): CreateFolderResult {
val userId = activeUserId ?: return CreateFolderResult.Error
val userId = activeUserId
?: return CreateFolderResult.Error(error = NoActiveUserException())
return vaultSdkSource
.encryptFolder(
userId = userId,
@@ -781,7 +833,7 @@ class VaultRepositoryImpl(
.flatMap { vaultSdkSource.decryptFolder(userId, it.toEncryptedSdkFolder()) }
.fold(
onSuccess = { CreateFolderResult.Success(folderView = it) },
onFailure = { CreateFolderResult.Error },
onFailure = { CreateFolderResult.Error(error = it) },
)
}
@@ -789,7 +841,10 @@ class VaultRepositoryImpl(
folderId: String,
folderView: FolderView,
): UpdateFolderResult {
val userId = activeUserId ?: return UpdateFolderResult.Error(null)
val userId = activeUserId ?: return UpdateFolderResult.Error(
errorMessage = null,
error = NoActiveUserException(),
)
return vaultSdkSource
.encryptFolder(
userId = userId,
@@ -814,21 +869,30 @@ class VaultRepositoryImpl(
)
.fold(
onSuccess = { UpdateFolderResult.Success(it) },
onFailure = { UpdateFolderResult.Error(errorMessage = null) },
onFailure = {
UpdateFolderResult.Error(
errorMessage = null,
error = it,
)
},
)
}
is UpdateFolderResponseJson.Invalid -> {
UpdateFolderResult.Error(response.message)
UpdateFolderResult.Error(
errorMessage = response.message,
error = null,
)
}
}
},
onFailure = { UpdateFolderResult.Error(it.message) },
onFailure = { UpdateFolderResult.Error(it.message, error = it) },
)
}
override suspend fun deleteFolder(folderId: String): DeleteFolderResult {
val userId = activeUserId ?: return DeleteFolderResult.Error
val userId = activeUserId
?: return DeleteFolderResult.Error(error = NoActiveUserException())
return folderService
.deleteFolder(
folderId = folderId,
@@ -839,7 +903,7 @@ class VaultRepositoryImpl(
}
.fold(
onSuccess = { DeleteFolderResult.Success },
onFailure = { DeleteFolderResult.Error },
onFailure = { DeleteFolderResult.Error(error = it) },
)
}
@@ -854,7 +918,8 @@ class VaultRepositoryImpl(
}
override suspend fun exportVaultDataToString(format: ExportFormat): ExportVaultDataResult {
val userId = activeUserId ?: return ExportVaultDataResult.Error
val userId = activeUserId
?: return ExportVaultDataResult.Error(error = NoActiveUserException())
val folders = vaultDiskSource
.getFolders(userId)
.firstOrNull()
@@ -877,7 +942,7 @@ class VaultRepositoryImpl(
)
.fold(
onSuccess = { ExportVaultDataResult.Success(it) },
onFailure = { ExportVaultDataResult.Error },
onFailure = { ExportVaultDataResult.Error(error = it) },
)
}
@@ -945,9 +1010,11 @@ class VaultRepositoryImpl(
initUserCryptoMethod: InitUserCryptoMethod,
): VaultUnlockResult {
val account = authDiskSource.userState?.accounts?.get(userId)
?: return VaultUnlockResult.InvalidStateError
?: return VaultUnlockResult.InvalidStateError(error = NoActiveUserException())
val privateKey = authDiskSource.getPrivateKey(userId = userId)
?: return VaultUnlockResult.InvalidStateError
?: return VaultUnlockResult.InvalidStateError(
error = MissingPropertyException("Private key"),
)
val organizationKeys = authDiskSource
.getOrganizationKeys(userId = userId)
return unlockVault(

View File

@@ -17,5 +17,5 @@ sealed class CreateAttachmentResult {
/**
* Generic error while creating an attachment.
*/
data object Error : CreateAttachmentResult()
data class Error(val error: Throwable) : CreateAttachmentResult()
}

View File

@@ -13,5 +13,5 @@ sealed class CreateCipherResult {
/**
* Generic error while creating cipher.
*/
data object Error : CreateCipherResult()
data class Error(val error: Throwable) : CreateCipherResult()
}

View File

@@ -15,5 +15,5 @@ sealed class CreateFolderResult {
/**
* Generic error while creating a folder.
*/
data object Error : CreateFolderResult()
data class Error(val error: Throwable) : CreateFolderResult()
}

View File

@@ -15,5 +15,5 @@ sealed class CreateSendResult {
/**
* Generic error while creating a send.
*/
data class Error(val message: String?) : CreateSendResult()
data class Error(val message: String?, val error: Throwable?) : CreateSendResult()
}

View File

@@ -16,5 +16,5 @@ sealed class DecryptFido2CredentialAutofillViewResult {
/**
* Generic error while decrypting credentials.
*/
data object Error : DecryptFido2CredentialAutofillViewResult()
data class Error(val error: Throwable) : DecryptFido2CredentialAutofillViewResult()
}

View File

@@ -13,5 +13,5 @@ sealed class DeleteAttachmentResult {
/**
* Generic error while deleting an attachment.
*/
data object Error : DeleteAttachmentResult()
data class Error(val error: Throwable) : DeleteAttachmentResult()
}

View File

@@ -13,5 +13,5 @@ sealed class DeleteCipherResult {
/**
* Generic error while deleting a cipher.
*/
data object Error : DeleteCipherResult()
data class Error(val error: Throwable) : DeleteCipherResult()
}

View File

@@ -13,5 +13,5 @@ sealed class DeleteFolderResult {
/**
* Generic error while deleting a folder.
*/
data object Error : DeleteFolderResult()
data class Error(val error: Throwable) : DeleteFolderResult()
}

View File

@@ -13,5 +13,5 @@ sealed class DeleteSendResult {
/**
* Generic error while deleting a send.
*/
data object Error : DeleteSendResult()
data class Error(val error: Throwable) : DeleteSendResult()
}

View File

@@ -14,5 +14,5 @@ sealed class DownloadAttachmentResult {
/**
* The attachment could not be downloaded.
*/
data object Failure : DownloadAttachmentResult()
data class Failure(val error: Throwable) : DownloadAttachmentResult()
}

View File

@@ -14,5 +14,5 @@ sealed class ExportVaultDataResult {
/**
* There was an error converting the vault data.
*/
data object Error : ExportVaultDataResult()
data class Error(val error: Throwable) : ExportVaultDataResult()
}

View File

@@ -16,5 +16,5 @@ sealed class GenerateTotpResult {
/**
* An error occurred while generating the code.
*/
data object Error : GenerateTotpResult()
data class Error(val error: Throwable) : GenerateTotpResult()
}

View File

@@ -17,5 +17,5 @@ sealed class RemovePasswordSendResult {
* Generic error while removing the password protection from a send. The optional
* [errorMessage] may be displayed directly in the UI when present.
*/
data class Error(val errorMessage: String?) : RemovePasswordSendResult()
data class Error(val errorMessage: String?, val error: Throwable?) : RemovePasswordSendResult()
}

View File

@@ -13,5 +13,5 @@ sealed class RestoreCipherResult {
/**
* Generic error while restoring a cipher.
*/
data object Error : RestoreCipherResult()
data class Error(val error: Throwable) : RestoreCipherResult()
}

View File

@@ -12,5 +12,5 @@ sealed class ShareCipherResult {
/**
* Generic error while sharing cipher.
*/
data object Error : ShareCipherResult()
data class Error(val error: Throwable) : ShareCipherResult()
}

View File

@@ -13,5 +13,5 @@ sealed class TotpCodeResult {
/**
* There was an error scanning the code.
*/
data object CodeScanningError : TotpCodeResult()
data class CodeScanningError(val error: Throwable? = null) : TotpCodeResult()
}

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